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
- Read
drive-motion.jsbefore touching the controller or view, and keep race math renderer-independent so it can be tested in Node.createRacemakes one player plus three AI opponents and a three-second countdown. - Advance physics with a fixed-step accumulator: add the clamped frame time, run
stepRacein wholeSTEPs, 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. - Preserve ordered gate progression in
updateProgress. Project withprojectTrack, handle distance wraparound, reject implausible discontinuities and off-road credit, and advancenextGatein order. Teleporting, reversing, resetting, or touching the finish without completing the course must never create laps. - Treat reset as recovery, not a shortcut:
resetToTrackrebases projection without granting progress.resolveCollisionsmust not push a car through a barrier or award checkpoints from a positional correction. - Keep
standingssemantics: finishers sort byfinishTime, unfinished racers by progress.stepRacestops once the player finishes, so test AI completion with the player held back and an explicit time bound. - Tune
aiInputwith 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
- Match Three.js's drawing-buffer rounding.
WebGLRenderer.setSizefloorswidth * pixelRatio. A resize check that usedMath.rounddisagreed 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. - Interpolate rendering between physics steps. Every racer keeps a
previouspose that is updated before each step and on reset. Render withinterpolateRacer(car, alpha, target), wherealpha = accumulator / STEP, interpolating heading along the shortest angle. Rendering the raw latest step at 60 Hz from a 120 Hz simulation visibly stutters. - 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.
- Remove z-fighting at the source: never leave coplanar overlapping road, path, or line meshes. Split or offset them and use small
polygonOffsetfor painted lines. Set the camera near/far planes tightly (0.5 to 450 m) for depth precision. - Shadows: cars cast but do not receive shadows, because detailed car self-shadowing shimmers. Tune
shadow.biasandshadow.normalBiastogether and fit the shadow camera to the visible track area. - Handle
webglcontextlostby callingpreventDefault(), pausing the race, disabling Start, and showing a recovery message. Onwebglcontextrestored, rebuild the view and let the player resume. A lost context must never look like a frozen crash. - Load cars asynchronously.
createDriveViewawaits 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
- 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. - 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.
- 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
- 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.
- 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. - 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. - 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
- Use the shared
arcade-audio.jsand keepdrive-motion.jssilent:drive.jscompares race state against a smallheardrecord once per rendered frame (after the fixed steps). CreatecreateEngineLoop()lazily while running, update it withabs(speed) / 34, and stop it on pause, restart, and finish. Playcountdownon each change ofceil(race.countdown), thengo;checkpointwhennextGateadvances andlapwhenlapsincreases;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); andwinorfinishonce at the end. CallunlockAudio()inside the Start click. Add#drive-sound(classarcade-sound) between Pause and Full screen. - Racing theme:
playMusic(THEMES.driveMyRide.url)when a race starts (it keeps playing while paused) andstopMusic({ 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, viaGenerate-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 withtools/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 inpublic/audio/provenance.json. Play themes withplayMusic(THEMES.<game>.url)fromarcade-audio.js: fetch plusdecodeAudioData(covered byconnect-src 'self'), a loopingAudioBufferSourceNodeon a music bus at -10 dB under the effects, a 1.5 s fade-in, crossfade between themes, andstopMusic({ fade }). EachTHEMESentry stores its exact loop length, and playback setsloopStart/loopEndto 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
- 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. - Touch: support simultaneous steer and throttle with multi-touch pointer capture, and clear each control on
pointerup,pointercancel, and lost capture. - Mobile: the shared
arcade-screen.jsputs 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
- 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). - 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), andtests/arcade-mobile-browser.py(fullscreen and Exit at phone sizes). Set the base URL and browser with theARCADE_TEST_BASEandARCADE_TEST_CHROMEenvironment variables. - Mock only account and Firestore network routes; never mock the simulation or renderer under test. Collect
pageerrorand console errors and fail on them. - 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.
- Check the exact production Content Security Policy on the full page, not on an isolated loader. Embedded GLB textures need
blob:in bothconnect-srcandimg-src. - 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.