These notes capture the browser/server approach that worked for full-lap map checks.
Use automation to drive a real race through the server physics while the display renders the normal cockpit view. This is not a substitute for phone-device feel testing, but it is useful for checking:
- A track can be completed through the authoritative race loop.
- New props do not obviously block the road.
- Lap/checkpoint/results logic still works.
- The cockpit camera sees sane scenery through the lap.
- Browser console errors and blank WebGL canvases are caught.
- Controller feedback/audio payloads can be smoke-tested without relying on display snapshots.
- Run
npm installfirst ifnode_modulesis missing. - Use
npm run typecheckfor the normal app, server, and playtest script TypeScript checks. - Use
npm run buildfor the normal TypeScript/Vite/server build check. - For browser automation, use Playwright temporarily through
npxinstead of adding it to app dependencies. - If Playwright's browser binary is missing, run
npx -y playwright@latest install chromium. - If Chromium is missing runtime libraries in a fresh container, run
npx -y playwright@latest install-deps chromium.
Reusable playtest scripts live under playtests/.
npm run playtest:lap -- --track alpineruns a pure WebSocket full-lap completion check. It creates a room through the server protocol, joins a controller, drives one lap, and prints JSON with finish state, surfaces, max center distance, progress marks, and results.npm run playtest:lap -- --track sakura --jsondoes the same with quieter output.- Add
--vehicle formula,--vehicle kart,--vehicle stockTruck, or--vehicle tukTukto test a specific vehicle class. Add--setup balanced,--setup highGrip, or--setup highSpeedfor vehicles that support multiple setups. npm run playtest:capture -- --track alpine --target 0.24:vistaruns one browser visual capture. It creates the display in Playwright, drives to the target progress, briefly brakes, saves a screenshot underplaytest-captures/, and prints JSON. Target values are0-1fractions or1-100percents, optionally followed by:label.- Add
--speed-scale 1.55when a very long track needs a faster automation pass. The default is1, so existing Sakura/Alpine behavior is unchanged. - Add
--progress-log-ms 30000toplaytest:lapfor long maps. It writes progress heartbeats to stderr so long runs do not sit silent for many minutes. - At startup,
playtest:capturedeletes and recreates its output folder. By default that isplaytest-captures/, so the repo only keeps the latest visual capture run. - Local capture output folders are ignored by git so screenshots do not pollute the worktree.
npm run typecheck:playtestschecks the playtest scripts only; the mainnpm run typecheckincludes this.
Both scripts expect the app server to already be running:
npm run devThe visual capture script requires Playwright. Use it without adding Playwright to project dependencies:
npx -y -p playwright@latest -c 'NODE_PATH=$(dirname $(dirname $(which playwright))) npm run playtest:capture -- --track alpine --target 0.24:vista'For Alpine, prefer one playtest:capture target per command. Fjord and Cloudline later completed multi-target visual runs in one browser process, so this seems map/environment dependent rather than a universal rule.
Nearby-rival audio has two useful automated checks:
- Protocol smoke: create a display room, join two controller sockets, start a race, drive one controller forward, and assert the other controller receives a non-empty
nearbyAudioCarsarray with distance, side/ahead, speed, throttle, relative speed, and closing speed. This proves the server is sending compact nearby-audio descriptors without sending full race snapshots to phones. - Browser audio smoke: open a real
/controllerpage in Playwright, inject a wrapper aroundAudioContextbefore app load, join/start from the controller UI, drive a second controller nearby, and assert nonzeroStereoPannerNode.pan.setTargetAtTime()plus rival gain automation. This proves the real browser/controller path creates and modulates the nearby-car audio graph.
These checks are not a substitute for real phone/earbud tuning. Headless Chromium can prove nodes and automation calls, but it cannot judge whether the mix is pleasant, too loud, too quiet, or fatiguing.
When a feature needs a real phone/human in the loop but still benefits from a repeatable opponent, create a normal display room and join an automation controller as the VIP bot. Keep the display and bot sockets open, configure the room, then have the bot wait in the lobby until a non-bot controller is connected and Ready.
The pattern that worked for nearby-rival audio testing:
- Start the built server with
npm start. - Create a display room over
ws://127.0.0.1:8787/ws. - Join a controller socket as
Fast Bot. - Set bot vehicle/setup to Formula Prototype / High Grip.
- Set room settings to Sakura Sprint, 5 laps, no warm-up, dry, assist on, reset off. Use ghost cars on when testing pass-by/relative audio so the human can focus on sound without bot contact ending the run.
- Print the room code and keep the bot in lobby.
- Human opens
http://localhost:8787/, joins that room code as a display if needed, scans the QR with a phone, selects Formula if needed, and tapsReady. - After detecting the human
Ready, the bot sendsvip_start_raceand drives the normal automated line-following loop at an aggressive speed scale.
This is useful for testing real phone audio/haptics/orientation behavior while keeping the other car predictable. Stop both long-running sessions when done so the server and bot socket do not stay alive in the background.
When identifying scenery or car parts from screenshots, open the dev-only asset gallery first:
npm run devThen visit http://127.0.0.1:5173/dev-assets.
- The gallery renders the live procedural prop components with their component names, grouped by generic track props, map props, standalone driver hands/paws, and vehicle/cockpit props. It also includes a vehicle colour lineup for checking every controller-selectable colour across the roster.
- It uses one WebGL canvas for the visible set so the
Allview can show every asset without hitting browser context limits. - The theme control defaults to
System, which followsprefers-color-scheme; switch to explicit dark or light only when comparing a specific mode. - Use search for quick identification, click either the rendered asset or the side index card to focus it, and use the side index for crisp component names.
- Use
Fitfor normalized previews andWorldto compare relative asset scale. - In focus mode, use the zoom buttons, trackpad pinch/ctrl-wheel zoom, or mobile pinch to inspect details; drag the focused canvas to pan while zoomed.
- Use the dark/light/system theme control and rain toggle to check contrast and wet-material variants.
- When validating the spin toggle, take several screenshots across the rotation, for example immediately, then roughly 900 ms, 1800 ms, and 2700 ms later. This catches assets with bad origins that orbit around a wide radius instead of rotating in place.
- For long asymmetric previews such as the start gantry plus grid boxes, verify both spin and rain toggles. If rain changes make the asset jump while spin is paused, the gallery centering or explicit preview pivot likely needs adjustment.
- The
/dev-assetsroute is gated by Vite'simport.meta.env.DEV; production builds should fall through to the normal app instead of the gallery. - Do not save gallery screenshots to the repo by default. Render assets on demand so the repo does not fill with generated images; use
playtest:captureonly when route context or before/after visual evidence is needed.
Best path found:
- Start the app with
npm run dev. - Use the server-served app at
http://127.0.0.1:8787/for automation. - Connect the automation controller directly to
ws://127.0.0.1:8787/ws.
Avoid using http://127.0.0.1:5173/ for long automated race checks when possible. The Vite dev server proxies /ws, and repeated automated browser opens/closes caused noisy proxy ECONNRESET / EPIPE logs. Port 8787 avoids that proxy layer.
- The server sends
helloimmediately after connection. A one-shot listener can miss it if attached after the socket opens. - Do not make automation depend on seeing
hello. - Attach the
joined_controllerlistener before sendingset_profile. - Use the display page to create the room, then read
sim-drive-display-sessionfromsessionStorageto get the display group id. - Send
set_profileover a separate Nodewsclient to create the controller player. - For pass/fail, trust the authoritative
room_state.resultspayload over the lastcontroller_feedbackcar. The final controller feedback can be one tick stale after results are collected. - After joining, send:
set_car_setupset_cockpit_styleif neededvip_set_settingsvip_start_race
- For single-lap visual checks, set
warmupStart: false. Otherwise the first pass is an untimed warm-up.
The practical controller loop:
- Listen to
controller_feedbackfor the current car. - Send
input_frameat about 30 Hz. - Use
nearestTrackPointandsampleTrackfromsrc/shared/tracks.ts. - Aim at a lookahead point along the track.
- Steer from heading error plus lateral error.
- Estimate upcoming curve from future heading changes.
- Lower desired speed for sharp curves, then brake if current speed exceeds desired speed.
- Use
highGrip,ghostMode: true, andstabilityAssist: truefor visual map checks. This reduces unrelated driving failures.
This is good enough for Sakura, Alpine, Fjord, and Keys Causeway at the default speed scale. Alpine needs conservative braking because it has longer fast sections and a heavier chicane. For Alpine, a desired-speed range around 4.6 to 14.5 with stronger braking on future heading deltas completed the lap reliably.
Cloudline is too long for the default review speed. Use --speed-scale 1.55 --progress-log-ms 30000 for Cloudline completion and visual checks; that kept the lap stable while finishing in about 11 minutes.
What worked:
- Full-page Playwright screenshots reliably captured the rendered cockpit view.
- For a full Sakura pass, a screenshot set at representative progress targets was enough to inspect the new scenery.
- Targeted short runs are useful: capture start/early, then separate mid/end runs if Chromium gets unstable.
- For longer tracks, split the test into a pure WebSocket completion pass and separate one-screenshot browser visual passes. The pure WebSocket lap is more reliable for proving finish/results, and the targeted browser runs are better for inspecting scenery.
- If a long browser visual command is otherwise silent long enough for the local tool session to be killed, wrap it with a simple shell heartbeat and keep the screenshot command itself unchanged.
What did not work:
canvas.toDataURL()produced black images. The WebGL canvas does not preserve the drawing buffer.gl.readPixels()is good for nonblank/varied pixel smoke checks, but not for human-readable screenshots.- Taking many full-page screenshots during one fast lap can skip large chunks of progress because screenshots are slow.
- On Alpine, repeated screenshots in one long browser-backed run caused Chromium to die after the first capture in this container.
- Helper scripts created under
/tmpdo not automatically resolve repo dependencies likews. Prefer the committedplaytests/scripts. If a throwaway helper is still needed, run it from the repo root or import repo-local packages by absolute path.
Workaround:
- Keep the automated driver slow for review laps.
- During a screenshot capture, briefly send
brake: 1,throttle: 0,steer: 0so the car does not jump far down the lap while the screenshot is being taken. - For visual inspection, prefer a few targeted captures over trying to capture every milestone in one run.
- For Alpine visual inspection, one screenshot per browser process was the most stable pattern in that run.
- Fjord and Cloudline both completed multi-target browser capture runs in the later long-map check, with no console errors.
- A visual capture target should fail if the race ends, crashes, or DNFs before reaching the requested progress. Do not accept an end-of-race screenshot as a successful target capture.
Target selection:
- Pick screenshots by what needs judgment, not by a fixed interval.
- Use start/early if the track's first impression changed.
- Use approach targets shortly before signature landmarks; the cockpit camera usually reads an upcoming feature better than a target placed exactly on top of it.
- Add one mid/late section when checking scenery repetition, sparse areas, or special route moments.
- Add finish approach when route closure or late-lap scenery changed.
- Keep long-map visual sets to roughly
4-6screenshots unless there is a specific issue to chase. Screenshots slow the automation and can destabilize long browser runs.
Sakura:
- Short enough that a browser-backed visual pass with multiple checkpoints was stable in the first scenery check.
- The committed generic driver can finish the lap but may briefly clip grass near the finish. Treat it as a route/results proof, not a perfect racing-line validator.
- Good targets after the current scenery pass: start/torii, blossom tunnel, mid-lap, finish approach.
- Avoid overhead transparent blossom canopy blobs. They can read as pink circles floating in the sky; keep the tunnel effect on the roadside trees instead.
- After removing the overhead canopy blobs, Sakura is cleaner but the blossom tunnel is lighter. If it needs more identity, add/scale roadside trees rather than floating canopy meshes.
- The large pale ground petal patches were cut down to fewer, smaller, lower-opacity decals. That reads cleaner from cockpit view and avoids the soft oval sky/cloud look at horizon distance.
Alpine:
- Use the more conservative driver profile from
playtests/lib/driver.ts; Alpine has longer fast sections and a heavier chicane than Sakura. - Split completion and visuals:
playtest:lapfor finish/results proof, then one-targetplaytest:captureruns for scenery. - A small amount of grass sampling is possible in the automated line; judge it against wall hits/DNF and max center distance, not as an automatic failure.
- Pay attention to the mountain silhouette in captures. The first attempt read like floating boulder stacks until foothill bases and low-poly mountain cores were added.
- Keep Alpine's skyline mixed rather than all jagged or all cone. The current direction is cone-dominant for a few large peaks, with some jagged peaks left for variety.
- The mixed cone/jagged mountains read better than all-jagged.
- The cable-car and chalet landmarks were enlarged and moved closer/earlier on the lap so they read as stronger signature moments without adding more rocks.
Fjord:
- The default driver finished cleanly. The line stayed on
road/briefcurbonly in the latest pass. - Multi-target browser capture stayed stable for waterfall/lookout/village/finish and for the earlier approach set.
- Water, distant peaks, road ribbon, and minimap readability are good.
- After the scenery pass, the village is larger and closer, with dock/sign detail, and the lookout has a clearer turnout/rail shape.
- The waterfall was strengthened with a broader rock face and larger water/mist planes, but should stay off-road enough that it remains a scenic cue rather than the dominant object.
- The late dark overhead section is treated as Fjord's tunnel/underpass moment. Do not remove it as an artifact unless it blocks visibility or clips through the car.
- Small cliff rails help the road edge read more like a fjord route. Avoid adding many more generic rocks; the map benefits from clean water/road views.
Keys Causeway:
- The default driver finished cleanly with road-only samples.
- The route is intentionally fully flat. Keep the long bridge straight as the first major impression and use scenery density, not elevation, for variety.
- Water, pale shoulders, bridge rails, and the old parallel bridge cue read clearly from the cockpit.
- Signature lighthouse/marina props should read from the cockpit but stay secondary to the bridge and water views. Keep them colorful and clear without turning them into road-blocking set pieces.
- For browser captures beyond the first bridge targets, use a heartbeat wrapper or split targets so quiet long drives are not killed before screenshots are written.
Cloudline:
- Use
--speed-scale 1.55 --progress-log-ms 30000for full-lap checks. The default driver is stable but too slow/silent for practical Cloudline completion. - The latest faster full-lap pass finished cleanly with road-only samples.
- Multi-target browser capture was stable at early climb, switchbacks, summit, descent, and finish.
- The vertical route and minimap read clearly. The road feels huge and playable.
- After the scenery pass, the summit observatory is closer/larger and has stronger marker poles/radio detail.
- Sparse snow poles and cliff breaks help interrupt the flat snow/cloud shelf without cluttering the whole map.
- The late lap remains intentionally clean. Add only high-signal ridge details if it needs more identity later.
Keep these as recent reference points, not permanent expected values. Update them when map geometry, physics, or driver logic changes meaningfully.
Sakura:
- Command:
npm run playtest:lap -- --track sakura --timeout-ms 90000 --json - Latest recorded result: finished, no crash, no DNF.
- Latest recorded finish time: about
31.849s. - Latest recorded max center distance:
7.58. - Surfaces sampled by the generic script: mostly
road/curb, briefgrass. - Latest visual recheck: early torii, after-torii, tunnel, mid-lap, finish, and reduced-petal captures had no browser console errors.
- Visual notes: torii reads well early; blossom identity should come from roadside trees; avoid overhead blossom blobs.
Alpine:
- Command:
npm run playtest:lap -- --track alpine --timeout-ms 120000 --json - Latest recorded result: finished, no crash, no DNF.
- Latest recorded finish time: about
59.879s. - Latest recorded max center distance:
8.25. - Surfaces sampled by the generic script: mostly
road, somecurb, briefgrass. - Visual smoke command:
npx -y -p playwright@latest -c 'NODE_PATH=$(dirname $(dirname $(which playwright))) npm run playtest:capture -- --track alpine --target 0.24:smoke --out-dir /tmp/simdrive-playtest-captures --timeout-ms 90000' - Latest visual recheck: mixed-mountains, rockwall, stronger cable, stronger chalet, and finish captures had no browser console errors.
- Visual notes: cable car, chalet, bridge, and rock-wall section are readable; keep the mountain skyline mixed cone/jagged and grounded with low foothill bases.
Fjord:
- Command:
npm run playtest:lap -- --track fjord --timeout-ms 420000 --json - Latest recorded result: finished, no crash, no DNF.
- Latest recorded finish time: about
411.027s. - Latest recorded max center distance:
6.63. - Surfaces sampled by the generic script:
roadwith a tiny amount ofcurb. - Latest visual command:
npx -y -p playwright@latest -c 'NODE_PATH=$(dirname $(dirname $(which playwright))) npm run playtest:capture -- --track fjord --targets 0.145:waterfall-approach,0.445:lookout-approach,0.675:village-approach,0.91:tunnel-finish --out-dir playtest-captures-fjord-refresh --timeout-ms 540000' - Latest visual recheck: waterfall approach, lookout approach, village approach, and tunnel/finish captures had no browser console errors.
- Visual notes: water and route read cleanly; village/lookout are more readable; waterfall is visible but intentionally secondary; late tunnel/underpass view is expected.
Keys Causeway:
- Command:
npm run playtest:lap -- --track causeway --timeout-ms 540000 --progress-log-ms 30000 --json - Latest recorded result: finished, no crash, no DNF.
- Latest recorded finish time: about
438.536s. - Latest recorded max center distance:
2.86. - Surfaces sampled by the generic script:
roadonly. - Latest visual command:
npx -y -p playwright@latest -c 'NODE_PATH=$(dirname $(dirname $(which playwright))) npm run playtest:capture -- --track causeway --targets 0.08:bridge-final,0.285:old-bridge-final --out-dir playtest-captures-causeway-early-final --timeout-ms 180000 --speed-scale 2.2 --width 1024 --height 640' - Follow-up visual command:
npx -y -p playwright@latest -c 'NODE_PATH=$(dirname $(dirname $(which playwright))) npm run playtest:capture -- --track causeway --targets 0.418:lighthouse-final,0.695:marina-final --out-dir playtest-captures-causeway-final --timeout-ms 300000 --speed-scale 2.2 --width 1024 --height 640' - Latest landmark recheck command:
npx -y -p playwright@latest -c 'NODE_PATH=$(dirname $(dirname $(which playwright))) npm run playtest:capture -- --track causeway --targets 0.418:lighthouse-recheck,0.695:marina-recheck --out-dir playtest-captures-causeway-landmark-recheck --timeout-ms 300000 --speed-scale 2.2 --width 1024 --height 640' - Latest visual recheck: bridge straight, old bridge, strengthened lighthouse, and strengthened marina captures had no browser console errors when wrapped with a shell heartbeat.
- Visual notes: bridge straight is strong; open-water bends intentionally breathe; lighthouse and marina are clearer side landmarks rather than dominant road-blocking objects.
Cloudline:
- Command:
npm run playtest:lap -- --track cloudline --timeout-ms 1800000 --speed-scale 1.55 --progress-log-ms 30000 --json - Latest recorded result: finished, no crash, no DNF.
- Latest recorded finish time: about
650.925s. - Latest recorded max center distance:
5.73. - Surfaces sampled by the faster script:
roadonly. - Latest visual command:
npx -y -p playwright@latest -c 'NODE_PATH=$(dirname $(dirname $(which playwright))) npm run playtest:capture -- --track cloudline --targets 0.12:early-climb,0.32:switchbacks,0.52:summit,0.70:descent,0.94:finish --out-dir playtest-captures-cloudline-refresh --timeout-ms 900000 --speed-scale 1.55' - Latest visual recheck: early climb, switchbacks, summit, descent, and finish captures had no browser console errors. A follow-up summit-only capture also had no console errors after moving the observatory closer.
- Visual notes: huge ascent/descent reads clearly; summit landmark reads a bit stronger; ridge details are intentionally sparse.
- This is not manual phone handling validation.
- It does not judge steering feel, haptics, phone orientation, or real player mistakes.
- A successful automation lap means the route is playable and visually plausible, not that the track is perfectly tuned.
- For long tracks, keep runs targeted and expect to adjust the driver loop per map.
- Run
npm run build. - Start
npm run dev. - Create a display room in Playwright on
http://127.0.0.1:8787/. - Join a controller through direct
ws://127.0.0.1:8787/ws. - Set the track and one-lap dry settings with warm-up off.
- Run a pure WebSocket automated driver until results show
finished. - Record max center distance, surfaces, crash/DNF state, and result status.
- Run targeted browser visual captures at start, feature sections, mid-lap, late-lap, and finish approach.
- Record console errors from those visual captures.
- Stop the dev server when done so no background sessions remain.
For Cloudline, run the completion and visual commands with --speed-scale 1.55; add --progress-log-ms 30000 to the completion command so progress is visible during the long pass.