← Original custom skills library Ryan's portfolio

Hometown racer

Original authored for Ryan's arcade, with AI assistance, from the project's race simulation, renderer, browser tests, and real player feedback. No internet or downloaded skill text is incorporated. Publication does not grant a blanket license to models, brands, or the rest of the site.

Architecture

public/drive-motion.js is the pure simulation: track, physics, checkpoints, AI, collisions, standings, and interpolation, with no DOM, renderer, Firebase, or account access. public/drive.js is the controller: optional verified-account showroom, public stock-car play, input, the fixed-step loop, HUD, results, and graphics-loss recovery. public/drive-3d.js is the Three.js view: road, scenery, start/finish gantry, cars from buildRideModel, the camera, and resize. public/arcade-screen.js supplies the shared mobile fullscreen and Exit button used by every arcade game. Units are metres and seconds. STEP is 1/120 s, LAPS is 3, GATES is 12, ROAD_HALF is 9, and the start line is at track distance START.

Simulation procedure

  1. Read drive-motion.js before touching the controller or view, and keep race math renderer-independent so it can be tested in Node. createRace makes one player plus three AI opponents and a three-second countdown.
  2. Advance physics with a fixed-step accumulator: add the clamped frame time, run stepRace in whole STEPs, and keep the remainder. Clamp large gaps after a background tab instead of simulating a jump. When the race finishes, drain the accumulator so no extra steps run behind the results panel.
  3. Preserve ordered gate progression in updateProgress. Project with projectTrack, handle distance wraparound, reject implausible discontinuities and off-road credit, and advance nextGate in order. Teleporting, reversing, resetting, or touching the finish without completing the course must never create laps.
  4. Treat reset as recovery, not a shortcut: resetToTrack rebases projection without granting progress. resolveCollisions must not push a car through a barrier or award checkpoints from a positional correction.
  5. Keep standings semantics: finishers sort by finishTime, unfinished racers by progress. stepRace stops once the player finishes, so test AI completion with the player held back and an explicit time bound.
  6. Tune aiInput with track-relative look-ahead, lane choice, corner speed, and stuck recovery. Prove AI quality with bounded multi-race tests that assert finite state, legal road position, ordered gates, laps, and finish times. A car turning a corner proves nothing. Players have said the computer drivers feel poor, so treat AI quality as open work: give opponents distinct but fair skill, racing lines, overtaking, and recovery rather than pure lane-following.

Rendering without flashing or jitter

  1. Match Three.js's drawing-buffer rounding. WebGLRenderer.setSize floors width * pixelRatio. A resize check that used Math.round disagreed on fractional phone sizes (390×843 at device pixel ratio 1.5) and reallocated and cleared the buffer every frame. That was the real cause of the reported flashing. Compare floored values, and resize only when they change.
  2. Interpolate rendering between physics steps. Every racer keeps a previous pose that is updated before each step and on reset. Render with interpolateRacer(car, alpha, target), where alpha = accumulator / STEP, interpolating heading along the shortest angle. Rendering the raw latest step at 60 Hz from a 120 Hz simulation visibly stutters.
  3. Smooth the chase camera's position and look target with frame-rate-independent damping. Snap only on start and reset so the camera does not sweep across the map.
  4. Remove z-fighting at the source: never leave coplanar overlapping road, path, or line meshes. Split or offset them and use small polygonOffset for painted lines. Set the camera near/far planes tightly (0.5 to 450 m) for depth precision.
  5. Shadows: cars cast but do not receive shadows, because detailed car self-shadowing shimmers. Tune shadow.bias and shadow.normalBias together and fit the shadow camera to the visible track area.
  6. Handle webglcontextlost by calling preventDefault(), pausing the race, disabling Start, and showing a recovery message. On webglcontextrestored, rebuild the view and let the player resume. A lost context must never look like a frozen crash.
  7. Load cars asynchronously. createDriveView awaits the shared detailed GLBs; show progress and a retryable error, and dispose every car, texture, and renderer on exit.

The finish line and race flow

  1. The visible finish must be exactly where lap logic counts it: place the gantry at trackAt(START), aligned with the road tangent. Use a two-sided START/FINISH banner on poles, a full-width checkered stripe, and "3 LAPS" boards so it reads clearly from the chase camera on a phone.
  2. HUD: show position, lap of 3, next checkpoint, and time without covering the road on a 320 px-wide phone. Announce the final checkpoint ("final checkpoint — head for the finish"). Players described the lap and checkpoint HUD as weird, so prototype layout changes with real screenshots in portrait and landscape before shipping.
  3. On finish, stop simulation input, show a results panel with standings and times, focus Restart, and offer "Choose another ride", which returns to the showroom. Pause, Resume, Restart, Reset, and Exit must be reachable by touch, mouse, and keyboard.

Cars, showroom, and sharing

  1. The three stock cars (EV9-inspired SUV, Bolt-inspired hatch, and the dream car) must always be selectable and drivable, labelled as defaults, without depending on the shared showroom. An empty, failed, or hanging shared-ride query must still let the player start with a stock car.
  2. Load shared designs only through parseSavedRide. Reject photo-only records clearly. An explicitly requested shared ride that fails to load must show an error, not silently swap in a default car.
  3. Keep stock-car racing publicly playable. Verified Google/GitHub accounts are required only for reading community designs; drop stale gallery responses after account changes, and keep one model API (buildRideModel) shared by garage and racer so scale, paint, decals, stickers, and wheels match.
  4. Wheels: front pivots steer about Y using the smoothed steering value, while a separate rolling child spins from signed simulated distance divided by tire radius. Never fake rolling from wall-clock time.

Audio

  1. Use the shared arcade-audio.js and keep drive-motion.js silent: drive.js compares race state against a small heard record once per rendered frame (after the fixed steps). Create createEngineLoop() lazily while running, update it with abs(speed) / 34, and stop it on pause, restart, and finish. Play countdown on each change of ceil(race.countdown), then go; checkpoint when nextGate advances and lap when laps increases; bump (min gap 0.35 s) when a rival comes within 3.85 units (edge-triggered) or speed drops by more than 1.5 in a frame without braking (ignored during a reset penalty); and win or finish once at the end. Call unlockAudio() inside the Start click. Add #drive-sound (class arcade-sound) between Pause and Full screen.
  2. Racing theme: playMusic(THEMES.driveMyRide.url) when a race starts (it keeps playing while paused) and stopMusic({ fade: .4 }) before the finish sting. The theme is a 128 BPM, 16-bar, 30 s loop. Generate with a local text-to-music model (we used ACE-Step 1.5 XL-SFT, MIT, via Generate-Audio.cmd -Style <preset> -Prompt "..." -Duration 48 -Seed 42) using an original instrumental prompt that names no artists or songs and asks for steady energy with no intro, breakdown, or ending. Do not trust the preset BPM: measure the real repeat period with tools/make-music-loop.py (onset autocorrelation plus a spectral self-similarity lag curve; the maze take came out at 110 BPM, not the preset 112), cut a bar-aligned 16-bar loop at the most self-similar start, crossfade the seam for 120 ms (equal power), apply linear gain to about -16 LUFS with at least 1 dB true-peak headroom, and encode AAC 112 kbps M4A (roughly 0.4 to 0.55 MB). Record model, preset, seed, steps, prompt, loop start, and loop length in public/audio/provenance.json. Play themes with playMusic(THEMES.<game>.url) from arcade-audio.js: fetch plus decodeAudioData (covered by connect-src 'self'), a looping AudioBufferSourceNode on a music bus at -10 dB under the effects, a 1.5 s fade-in, crossfade between themes, and stopMusic({ fade }). Each THEMES entry stores its exact loop length, and playback sets loopStart/loopEnd to that window (skipping any AAC priming silence the browser keeps, with a 2 ms tolerance), so the loop never clicks or gaps. Music waits for the first gesture, shares the mute button, pauses with the hidden page, and fails silently if a file is missing. The service worker caches theme files at runtime (cache-first) rather than precaching about 1.5 MB, so filenames are immutable: ship a new take under a new name.

Input and devices

  1. Desktop: map physical keys by event.code: W or Up for throttle, S, Down, or Space for brake, A/D or Left/Right to steer, and R to reset; P or Escape pauses and P resumes. Ignore key events from form controls, dialogs, the account menu, IME composition, and Ctrl/Alt/Meta shortcuts, so typing or browser shortcuts never drive the car. Focus the canvas on start, prevent page scrolling only while racing, and clear held input on blur, visibility change, pause, account change, or mode change.
  2. Touch: support simultaneous steer and throttle with multi-touch pointer capture, and clear each control on pointerup, pointercancel, and lost capture.
  3. Mobile: the shared arcade-screen.js puts the game into a focused full-window mode and requests browser fullscreen from the Start gesture where supported. Where fullscreen is unavailable (for example iOS Safari), the game still fills the window and says so. A top-right Exit leaves fullscreen and focused mode. Players preferred the mobile experience over the first desktop version, so verify both explicitly.

Testing

  1. Unit tests from web: node --test tests/drive.test.js tests/ride.test.js tests/ride-model.test.js tests/arcade-screen.test.js. Cover countdown, the exact finish boundary, finish order, reset without credit, backwards travel, wraparound, rejected shortcuts, collision bounds, bounded AI completion, and interpolation (including heading wrap).
  2. Browser tests (Python Playwright with headless Chromium and software WebGL via --use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader): tests/drive-browser.py (showroom, stock and empty/failed shared lists, race flow), tests/drive-desktop-browser.py (keyboard and focus), tests/drive-render-browser.py (buffer-resize counts at fractional DPR, finish gantry, results, context loss), and tests/arcade-mobile-browser.py (fullscreen and Exit at phone sizes). Set the base URL and browser with the ARCADE_TEST_BASE and ARCADE_TEST_CHROME environment variables.
  3. Mock only account and Firestore network routes; never mock the simulation or renderer under test. Collect pageerror and console errors and fail on them.
  4. Accelerated "teleport to the finish" tests can hide real timing bugs. Also drive a normal-speed final approach across the line and assert the results panel appears without errors.
  5. Check the exact production Content Security Policy on the full page, not on an isolated loader. Embedded GLB textures need blob: in both connect-src and img-src.
  6. Software WebGL timings are not real-device performance. Measure frame time and buffer reallocations as evidence, and confirm feel on a real phone and a real desktop before claiming it is smooth.

Acceptance

No skipped checkpoints, free laps, stuck AI hidden by a short smoke test, invalid saved designs, bypassed account checks, held controls, per-frame buffer reallocation, or unexplained exceptions at the finish. Report bounded simulation results, browser checks, and remaining manual verification separately and honestly, including open player feedback (HUD clarity, AI quality, and model polish).

Install this custom skill

Review and read the raw SKILL.md or download SKILL.md. Create a folder named hometown-racer in your agent's supported skills directory and save the file there as SKILL.md.

Project-local Copilot example: .github\skills\hometown-racer\SKILL.md. Personal installation, only if you choose it: .agents\skills\hometown-racer\SKILL.md under your profile. Copy only this authored file, not any downloaded skill collection. Inspect another project's code before adapting these Ryan-specific workflows.

The instruction file is usable independently of MCP. Local MCP setup is a separate option; no public MCP protocol endpoint is deployed.

Catalog name: hometown-racer. Source: original-custom. Size: 12760 UTF-8 bytes. JSON catalog and provenance.