This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in node_modules/next/dist/docs/ before writing any code. Heed deprecation notices.
- Framework: Next.js 16.2.4 (Turbopack)
- 3D Rendering: Three.js (r184), orthographic camera, toon-shaded
- Database: Drizzle ORM + postgres.js (CockroachDB via DATABASE_URL)
- Auth: WorkOS + jose (JWT in httpOnly cookies)
- Multiplayer: Apinator (
@apinator/clientWebSocket SDK) — bidirectional. Client sends position/events viaapinator.trigger()directly through WebSocket. Receives remote players viachannel.bind(). Players in different stages see each other on the same world map. - Deploy: Cloudflare Workers (via @opennextjs/cloudflare) and/or Vercel
src/
components/
GameMap.tsx # Main game component (~7400 lines)
game/
types.ts # All shared types, constants, enums
helpers.ts # Pure utility functions (materials, sprites, visuals, collision)
city.ts # City layout (roads, buildings, plaza, shops, auto-rickshaws)
app/
game/page.tsx # Game page (auth wrapper, renders GameMap)
api/ # All API routes (profile, auth, arena, etc.)
hooks/
useMultiplayer.ts # WebSocket receive + HTTP POST send via `/api/multiplayer`
db/
index.ts # Drizzle ORM setup
schema.ts # Database schema
GameMap.tsx is a single React client component with:
- Tutorial state, money, workshop/arena UI, quest progress
- Three.js scene objects (groups, meshes, cameras)
- Player position, collision hitboxes, customer NPCs
- Input state (keyboard), animation loop handle
- Auto-rickshaw and cloud animation data
- Scene setup — Creates Three.js renderer, camera, lights, builds city, NPCs, rooms
- Tutorial & quest sync — Mirrors state to refs for animation loop access
- Multiplayer — Join/leave events, remote avatar management
- Arena — Battle challenge/accept/decline flow
Runs at ~60fps via requestAnimationFrame:
- Read keyboard input → compute movement → collision check → update position
- NPC AI (Sparky path-walking, workshop customer movement)
- Animate remote avatars (lerp toward target positions)
- Animate environment (clouds drift, auto-rickshaws patrol, lights flicker)
- Camera follow (lerp toward player, room zoom transitions)
- Render scene
All TypeScript interfaces (RobotVisual, CustomerNpc, Hitbox, etc.) and game constants (ISLAND_RADIUS, MOVE_SPEED, NPC positions). Import these when adding new game objects.
Pure functions with no React dependencies:
createToonMaterial/createTexturedToonMaterial— MeshToonMaterial with gradient mapcreateLabelSprite/createNameSprite— Canvas-based text spritescreateRobotVisual/createHumanVisual/createPlayerSprite— 3D character buildersanimateRobotVisual— Per-frame animation (walk bob, pupil tracking)collidesWithAny/isInsideHitbox— Collision detectiondisposeObject— Cleanup Three.js geometry/materials
Procedural city generation:
buildCity(outdoorGroup)— All roads, buildings, plaza, shops, trees, propsbuildObstacles()— Returns Hitbox[] for collisioncreateBigPetShop/createBazaarShop— Building blueprintscreateAutoRickshaw— 3-wheeler vehicle visible on roads
CanvasTexture.flipYdefaults totrue— Three.js auto-flips vertically (canvas Y-down → WebGL Y-up).- If text on a BoxGeometry face appears upside-down, set
st.flipY = false. - If text appears mirrored (backwards), the face normal is reversed — use
scale.x = -1on the mesh to mirror the texture horizontally. - For a BoxGeometry sign on a building facade: use
MeshBasicMaterial({ map: st })withst.flipY = false. If the text reads backwards, the mesh is on the wrong side of the face or the face normal points inward. Orthographic camera (top-down, z-up), viewHeight=26 units, ACESFilmic tone mapping, PCFSoft shadows, Fog at 38-58 units for depth. No post-processing (EffectComposer) — would need to import fromthree/examples/jsm/postprocessing/.
Every visible 3D object in a room (furniture, counter, machine) must have a corresponding collision hitbox in that room's obstacle array (workshopObstaclesRef.current, shopObstaclesRef.current, etc.). The hitbox should match the object's actual dimensions. Hitboxes are defined as { shape: 'box', center: { x, y }, halfWidth, halfHeight } or { shape: 'circle', center: { x, y }, radius }.
- Cloudflare Workers (NOT Pages):
./scripts/deploy.sh(readsNEXT_PUBLIC_APINATOR_APP_KEYfrom.dev.varsautomatically) - Config:
wrangler.jsoncMUST exist — it's the Worker entry point. Do NOT delete it. - Vercel:
vercel(standard Next.js deployment) - Build output:
.open-next/(Cloudflare) or.next/(Vercel) - Secrets:
npx wrangler secret put <NAME>for.dev.varsentries on Cloudflare - IMPORTANT: Do NOT hardcode
NEXT_PUBLIC_APINATOR_APP_KEYin build commands. Always use./scripts/deploy.shor export it from.dev.vars. - Cloudflare secrets: After first deploy, set these with
npx wrangler secret put <NAME>for each entry in.dev.vars:WORKOS_API_KEY— JWT signing key for authDATABASE_URL— CockroachDB connection string (postgres://...)NEXT_PUBLIC_APINATOR_APP_KEY— WebSocket pub/sub app keyAPINATOR_SECRET— HMAC secret for channel auth & server SDKAPINATOR_APP_ID— App UUID for@apinator/serverREST API (.dev.varsis only for local dev; secrets are NOT auto-deployed with the worker.)
The file src/db/migrate.ts is DESIGNED to be safe — it uses
CREATE TABLE IF NOT EXISTS and ALTER TABLE ADD COLUMN IF NOT EXISTS
only. It will NEVER drop or overwrite data.
- Add it to
src/db/schema.ts - Add
ALTER TABLE ... ADD COLUMN IF NOT EXISTStosrc/db/migrate.ts - Run:
npx tsx src/db/migrate.ts
THIS IS EXTREMELY DANGEROUS. DO NOT DO IT WITHOUT EXPLICIT USER APPROVAL.
- Create a SEPARATE script (e.g.,
src/db/migrate_dangerous.ts) - Require
CONFIRM_DESTRUCTIVE=yesenv var at the top:if (process.env.CONFIRM_DESTRUCTIVE !== 'yes') { console.error('Set CONFIRM_DESTRUCTIVE=yes to confirm you want to destroy data.'); process.exit(1); }
- Never run this against production unless the user explicitly asks you to.
If you, an AI agent, are about to run any database operation, STOP and think:
- Am I about to drop tables? → DO NOT. Use ALTER TABLE instead.
- Am I about to run a migration? → Check that it uses CREATE TABLE IF NOT EXISTS / ALTER TABLE ADD COLUMN IF NOT EXISTS.
- Could this delete user data? → If yes, DON'T DO IT. Ask the user first.
Convert all post-cutscene dialogs to typewriter style, add letter icon + 3D model, and build the full battery-as-purchasable-component feature with install flow.
- All modals after cutscene (battery dialog, Rafiq letter, workshop intro, Rafiq no-letter) must use the bottom-bar typewriter pattern (30vh, speaker icon, blinking cursor, Enter button).
- Rafiq without the letter says "Who are you?" via typewriter dialog (pre-cutscene) or reopens workshop intro (post-cutscene).
- Battery is a separate purchasable component (NOT a repair stage part like sensor/voice/nav). Purchasable at
unit1-donestage for ~$20. - Letter must have a 2D icon (
createPartIcon) and a 3D model (createPartModel) that appears in the player's hand when held. - No rot180 in carry/orbit phases (confirmed correct by user); placement has rot180 (180° rotated when lowered).
- Door marker only shows after Sparky arrives home (
sparkyHomeArrivedRef.currentguard added to theuseEffect). - DO NOT change the intro cutscene or tutorial teaching of 4 variables. Battery install is purely cosmetic — Scrap powers up, Sparky says "go buy a sensor."
- Battery component: sold at Parts Shop (6.0, -12.0), costs $20, requires
unit1-donestage. - After buying battery → enter apartment → install-battery mini-cutscene (Sparky walks to Scrap, opens chest, places battery, chest glows).
- After install: battery removed from backpack,
batteryInstalledRef = true, Sparky modal: "Think you need a sensor — go buy one at the Parts Shop near the lake." - No quest stage change from battery install — sensor install still advances
unit1-done → unit2.
intro → cutscene → Rafiq workshop → tutorial (Variables & Data Types) → unit1-done
→ { buy battery ($20) → install (cosmetic) } + { buy sensor ($5) → install → unit2 }
→ String Methods tutorial → unit2-done → { buy voice ($10) → install → unit3 }
→ ... → all-done
| Location | Position | Size/Notes |
|---|---|---|
| Player spawn | (0, -7) | Outdoor world, ISLAND_RADIUS=40 |
| Parts Shop | (6.0, -12.0) | 8x4, door at (6.0, -10.2) |
| Rafiq's Shop (workshop) | walls (-6, -11.8) hw=3.70 hh=1.20 | door at (-6, -10.3) |
| Apartment building | (-6, -3.5) | 8x2.8, door at x=-3.6 |
| Apartment spawn | (0, -1.5) | Room interior |
| Sparky in apartment | (0.2, 2.2) | scale 0.7 |
| Scrap in apartment | (-2.6, 1.2, 0.24) | scale 0.65, after cutscene |
| Sparky outdoor | (-2.87, -6.1) | Repair kiosk |
| Rafiq in workshop | (2.35, 1.95) | ROOM_OWNER_POS |
| Workshop spawn | (0, -3.7) | Room interior |
| Parts shop spawn | (0, 1.2) | Room interior |
| Arena | (18.75, -12) | 7x3.5 |
- Player collision radius: 0.48
- Move speed: 7.4
- Talk to Sparky: 1.7 (SPARKY_INTERACTION_DISTANCE)
- Talk to Rafiq (workshop): 1.8 (hardcoded)
- Talk to customers: 1.25
- Register zone: 2.1 radius
Reference: Electrocute dialog at GameMap.tsx ~6087-6142.
State: { showXxxDlg: boolean, xxxStep: number, xxxText: string }
Steps: { speaker: string, text: string }[]
Effects: One for typewriter (35ms char interval), one for Enter key handler.
JSX: fixed/inset-0/flex-col/justify-end, 30vh bottom bar, gradient bg, speaker SVG, blinking ▌cursor, Enter button.
Full pattern reference:
// Typewriter effect
useEffect(() => {
if (!showXxxDlg) return;
setXxxText('');
let i = 0;
const interval = setInterval(() => { i++; setXxxText(step.text.slice(0, i)); if (i >= step.text.length) clearInterval(interval); }, 35);
return () => clearInterval(interval);
}, [xxxStep, showXxxDlg, xxxDlgSteps]);
// Enter key handler
useEffect(() => {
if (!showXxxDlg) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); if (nextStep < xxxDlgSteps.length) setXxxStep(nextStep); else { setShowXxxDlg(false); /* cleanup */ } } };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [showXxxDlg, xxxStep, xxxDlgSteps.length]);Separate from aptCutscenePhaseRef — uses installBatteryPhaseRef with phases:
'idle' | 'walk-to-scrap' | 'open-chest' | 'place-battery' | 'chest-glow' | 'done'
Triggered in runApartmentSparkyInteraction() when battery in backpack, not yet installed.
GameMap.tsx controls UI visibility during cinematic cutscenes through a centralized ref + function system.
hideGameUiRef(useRef(false)) — single source of truth for whether mission text, money animation, backpack, and mission modal should be hidden. Onlytrueduring cinematic cutscenes (intro, battery install). Never set during dialog-only interactions (Rafiq workshop conversation, Sparky outdoor dialog).startCinematicCutscene()— setscinemCamActiveRef.current = trueANDhideGameUiRef.current = trueendCinematicCutscene()— sets both tofalse
What gets hidden when hideGameUiRef.current === true
| UI Element | Line | Guard |
|---|---|---|
| Mission modal (NEW MISSION popup) | ~1088 | !hideGameUiRef.current added to trigger condition |
| Persistent mission box (bottom-left) | ~6810 | !hideGameUiRef.current added |
| Money bill animation (flying coins) | ~6832 | !hideGameUiRef.current added |
| Backpack (bottom-center) | ~7392 | !hideGameUiRef.current added |
What does NOT get hidden
- Money wallet (bottom-right
$display at ~6788) — intentional, always visible - Exclamation markers (3D scene markers) — hidden via
syncMarkersusingcinemCamActiveRef.currentdirectly (line ~6199) - Typewriter dialogs (TFB component) — always shown, they ARE the cutscene
- Workshop panel / shop UI — room-specific, managed separately
| Cutscene | Entry | Exit |
|---|---|---|
| Intro apartment cutscene (Sparky electrocute) | startCinematicCutscene() at ~3789, ~4016 |
endCinematicCutscene() at ~5401 |
| Rafiq letter cutscene | (no startCinematicCutscene — camera stays normal; markers hidden via syncMarkers rafiqWalkPhaseRef !== 'idle') |
|
| Battery install cutscene | startCinematicCutscene() at ~3795, ~4040, ~6463 |
endCinematicCutscene() at ~5465, ~7128 |
- Rafiq letter cutscene (walk to Rafiq + hand letter) does NOT call
startCinematicCutscene()— camera stays normal. However,cinemCamActiveRefis still checked: the workshop Space handler, Rafiq prompt display, and Rafiq interaction handler all guard againstcinemCamActiveRef.current === trueORrafiqWalkPhaseRef.current !== 'idle'— preventing Space-triggered interactions during ANY cutscene. - Interaction blocking: ALL Space key handlers must check
!cinemCamActiveRef.currentAND (where applicable) cutscene-specific phase refs (rafiqWalkPhaseRef.current === 'idle'). This is enforced in the workshop Space handler at ~3821, the Rafiq prompt at ~6361, and the Rafiq interaction at ~6372. - Rafiq / Sparky dialog-only interactions never touch
hideGameUiRef. All UI stays visible. syncMarkers()is called via auseEffect(deps: quest stage, room, backpack, etc.).startCinematicCutscene()/endCinematicCutscene()increment acutsceneTickstate, which is added to the effect's dependency array — sosyncMarkers()fires automatically when a cutscene starts or ends. No per-frame work in the animation loop.- Customer NPC
!markers are guarded separately at ~5640 viarafiqWalkPhaseRef.current === 'idle'— they never show during any cutscene phase. - To add a new cinematic cutscene: call
startCinematicCutscene()at entry andendCinematicCutscene()at completion. Do NOT setcinemCamActiveRef.currentdirectly. - To add a new non-cinematic cutscene (camera stays normal): use a dedicated phase ref (e.g.,
rafiqWalkPhaseRef). Add!cinemCamActiveRef.current && phaseRef.current === 'idle'guards to the Space handler and interaction prompts.
The "Workshop guide" button was deleted (line ~98). Rafiq is the workshop guide — talk to him for guidance.
- Native Web Speech API (
window.speechSynthesis) is tried first - Puter.js (
puter.ai.txt2speech()) is the fallback whengetVoices().length === 0 - Puter.js script loaded via
<Script>inlayout.tsxfromhttps://js.puter.com/v2/
- Check
speechSynthesis.getVoices().length === 0→ use puter.js fallback - Otherwise → native
SpeechSynthesisUtterancewithcancel()+setTimeout(speak, 10)
- Both native and puter.js paths pre-compute word boundaries via
/\S+/gregex - Timer-based fallback (220ms interval): advances through
wordBounds[], works on Firefox/Linux whereonboundarynever fires onboundaryhandler overwrites timer position on browsers where it fires (more precise)- First word highlighted immediately via
ttsCharIndexRef.current = wordBounds[0]?.start ?? null
ttsUtteranceRef— the nativeSpeechSynthesisUtterance(or{}sentinel for puter)ttsCharIndexRef— current word start index for underlineputerTtsAudioRef/puterTtsTimerRef— puter.js cleanup refsttsTick(useState) — incremented to force re-render on word boundary
Module-level React component outside GameMap (prevents remounting).
Props: show, step, steps, text, icon, onEnter, ttsOn, ttsCharIdx, onTtsToggle, hideEnter, codeBlocks.
Renders 30vh bottom bar with speaker icon, name, TTS play/stop button, typewriter text with word underline, and Enter button.
The file GameMap.tsx is ~7300 lines with complex state/ref/effect interdependencies.
A bad edit can silently break the entire game. Protect main at all costs.
This branch strips out the tutorial system and old sensor/voice/nav progression. After battery install cutscene → Scrap follower → free roam. No tutorial, no Sparky dialogs about sensors.
git checkout -b <feature-name>— create branch frommaingit commitafter each logical changegit push -u origin <branch>— if you need to share/deploy from branch- Only merge to
mainafter the user explicitly confirms and tests the branch
git checkout main— return to known-good stategit branch -D <broken-branch>— delete the bad branchgit checkout -b <new-feature>— start fresh from main
git add -A && git commit -m "<message>"
git push -u origin <branch>
./scripts/deploy.shThis builds with opennextjs-cloudflare and deploys to https://robocode.rahejaom.workers.dev.
The deploy script reads NEXT_PUBLIC_APINATOR_APP_KEY from .dev.vars automatically. After first deploy, set secrets with:
npx wrangler secret put WORKOS_API_KEY
npx wrangler secret put NEXT_PUBLIC_APINATOR_APP_KEY(Values from .dev.vars — these are NOT auto-deployed with the worker.)
vercelAfter completing ANY changes to GameMap.tsx or other game code:
git add -A && git commit -m "<description of changes>"git push -u origin <branch>./scripts/deploy.shThis ensures the branch is always deployed and testable onhttps://robocode.rahejaom.workers.dev. Do NOT merge tomainbefore the user confirms the branch works in production.
Fix robot orientation in repair cutscene — robot was rotated wrong in the customer's arms.
-
Robot carry rotation fix (
setCustomerRobotMode,'carry'):- Rotation:
(0, 0, PI/2)— 90° in XY plane so face normal stays +Z (upward), head on left, feet on right, thin profile from front - Position:
(0.105, 0.22, 0.11)— X-offset centers body, Y-forward clears torso by ~0.6mm (negligible overlap), Z at hand level for support - Scale:
0.35(unchanged)
- Rotation:
-
Robot place-robot phase fix (repair cutscene completion):
- Same transform applied: position
(0.105, 0.22, 0.11), rotation(0, 0, PI/2), scale0.35
- Same transform applied: position
-
Arm cradling pivot direction fix (
GameMap.tsx ~6014-6015):- Left arm
rotation.y:0.1→-0.1(INWARD instead of outward — moves hand toward center) - Right arm
rotation.y:-0.1→0.1(INWARD instead of outward — moves hand toward center) - Fix applies to all customers in
waitingstage (cradling pose)
- Left arm
With rotation (0,0,PI/2) and position (0.105,0.22,0.11), scale 0.35:
- Face normal → +Z (upwards) ✓
- Body back at Y=0.116 is 0.6mm inside torso Y-edge 0.122 (negligible overlap, resolves with physics margin)
- Body front at Y=0.280, torso front at ~0.122 → robot extends forward of torso (visible, natural)
- Body bottom Z=0.206 within hand sphere (0.176-0.230) → hands support from below ✓
- Hand front-surface Y=0.104 < body back Y=0.116 → hands cup from behind ✓
- Inward arm pivots (now
-0.1/0.1) bring hands 12mm closer to center → better front-cradle grip
Current branch: customer-queue-system. No new branch created — fixes applied directly.
Fix three bugs in workshop repair flow: empty customer typewriter dialog, robot color not matching petColor, and green emissive glow overriding robot color.
-
Removed customer typewriter dialog from repair cutscene:
- Deleted
REPAIR_DLG_STEPS,showRepairDlg,repairStep,repairTextstate - Glow phase now transitions directly to
'place-robot'(skips'dialog'phase) - Removed
<TFB>JSX block for repair dialog - Customer no longer says anything — cutscene is glow → place-robot → done
- Deleted
-
Robot 3D color now matches
petColor(createCustomerCargoRobotat line 780):- Added
PET_COLOR_HEXmap:{ red: 0xef4444, blue: 0x3b82f6, ... } - Changed from
hashColor(\robot-${customerName}`)toPET_COLOR_HEX[petColor]` - Both spawn sites pass
request.petColoras the second argument
- Added
-
White glow instead of green (
GameMap.tsx ~5708):- Changed
mat.emissive.setHex(0x22c55e)→mat.emissive.setHex(0xffffff) - Reduced intensity multiplier:
intensity→intensity * 0.6
- Changed
Change robot from customer's arms to ground follower during workshop leaving phase.
-
Skip
blockCustomerfor leaving NPCs (GameMap.tsx~5973/5978/5982):- Added
npc.stage === 'leaving' ||bypass to all three movement checks - Leaving customers can walk through northbound shifting customers freely
- Other customers still can't walk through the leaving NPC (excluded from their blockCustomer check)
- Added
-
Robot placed on ground in place-robot phase (
GameMap.tsx~5717-5728):- Changed from
sn2.visual.root.attach(robotRoot)→workshopRoomGroupRef.current?.attach(robotRoot) - Position: behind customer
(sn2.position.x, sn2.position.y - 0.5, 0.24) - Rotation:
(PI/2, 0, 0)(standing upright, same as register dock) - Scale:
0.18(ground scale, same as register dock)
- Changed from
-
Robot follower animation in leaving stage (
GameMap.tsx~5996-6010):- First frame: detach from customer → attach to room group, init position
- Each frame: lerp position toward point 0.5 units behind customer (
lerp factor 0.08) rotation.zmatched to customer's facing directionanimateRobotVisualcalled with walk speed when moving
-
Cargo robot disposal on exit (
GameMap.tsx~5932-5933, 5952-5953):- Detach from parent +
disposeObject(npc.cargoRobot.root)in both exit paths - Prevents ghost robot being left in the room
- Detach from parent +
customer-queue-system (committed + pushed + deployed)
Fix scrap follower closure bug, scrap body donut issue, add per-line TTS to laptop code prompts, and standardize cutscene interaction blocking.
-
Scrap follower not showing fix (
GameMap.tsx~4875, ~5967):scrapVisibleReact state was used in the animation loop closure (alwaysfalse). AddedscrapVisibleRefand usedscrapVisibleRef.currentin the animation loop instead.
-
Scrap torus body only during battery install (
GameMap.tsx~3314-3335, ~5845-5930):- Removed permanent body replacement at scene setup (cylinder → torus was done immediately, making Scrap a donut forever).
- Body is now replaced with a torus + chest panel only when the battery install
'open-chest'phase starts. - Body is restored to the original cylinder after
'done'phase, preserving any glow children. - Added
scrapOrigBodyRefto store the original mesh for restoration.
-
Per-line TTS in laptop code prompts (
GameMap.tsx~8488-8510):- Replaced prose instruction text with numbered code template lines + TTS play buttons, matching the customer spec sheet pattern.
- Each mode shows its expected code lines: name (1 line), date (3 lines), version (2 lines), boot (1 line).
- Uses
playLineTts+renderFormattedSpecLine+ttsActiveTextRefper-line play/stop toggle.
-
Cutscene interaction blocking (
GameMap.tsx~3821, ~6361, ~6372):- Workshop Space handler now guards against
!cinemCamActiveRef.current && rafiqWalkPhaseRef.current === 'idle'. - Rafiq prompt and interaction handler same guards.
- Prevents Space-triggered interactions during ANY cutscene (cinematic or Rafiq walk).
- Workshop Space handler now guards against
-
Deployment (
scripts/deploy.sh): AddedCLOUDFLARE_API_TOKENloading from.dev.vars. Stored token in.dev.vars+ saved old token as comment. Addedaccount_idtowrangler.jsonc. Set Cloudflare secrets (4 total). Migrations applied.
- All cutscene interaction guards consolidated to check
!cinemCamActiveRef.current(forstartCinematicCutscene()-managed) ANDrafiqWalkPhaseRef.current === 'idle'(for Rafiq walk cutscene). - Laptop code prompts use the same numbered-line TTS pattern as customer spec sheets for consistency.
main (committed + pushed + deployed)
Playwright-based test that simulates a real player exploring the game world. Takes labeled screenshots at various positions and camera angles.
Usage:
node screenshot_test.jsOutputs test_*.png files to /tmp/.
- Auth: Reads
WORKOS_API_KEYfrom.dev.vars, creates a JWT cookie - Modal dismissal: Clicks "Got it!" button via JS, presses Escape, closes X buttons
- Camera rotation: Acquires pointer lock on the canvas, then dispatches
PointerEvent('pointermove')withmovementX/movementY— the game's camera handler processes these whenisLockedRef.current === true - Movement: WASD keyboard events via Playwright
- Screenshots: Labeled and saved to
/tmp/test_*.png
| Helper | What it does |
|---|---|
dismissModals() |
Clicks "Got it!", Escape, close X buttons |
acquirePointerLock() |
Calls canvas.requestPointerLock() via JS |
rotateCamera(movementX, movementY, steps) |
Dispatches pointermove events with movementX/movementY properties over N steps |
walk(keys, ms) |
Holds WASD keys for ms milliseconds |
screenshot(label) |
Saves labeled PNG to /tmp/test_<label>.png |
The game uses PointerEvent.movementX/movementY for camera orbit when pointer lock is active. In headless Chromium, requestPointerLock() + dispatched events work — the game's onPointerMove handler processes them and updates yawRef/cameraPitchRef. Each movementX unit rotates ~0.012 rad, each movementY unit tilts ~0.005 rad.
| Building | Position | Height |
|---|---|---|
| Top-left (concrete) | (-5.7, 4) | 3.5 |
| Top-center (brick) | (6, 4) | 2.5 |
| Top-right (slate) | (18.5, 4) | 3.0 |
| Mid-right (slate) | (18.5, -4) | 2.0 |
| Bottom-right (wood) | (18.5, -11.75) | 2.5 |
To get a good building screenshot: walk the player within 3–5 units of a building, then rotateCamera() to face it from the side (negative movementX = look left, positive = look right, negative movementY = look up).
- Run
node screenshot_test.jsafter ANY visual change to buildings, roads, or scene objects - Review EVERY screenshot before proceeding — do not skip or rush
- Be extremely strict when reviewing: look for Z-fighting, missing geometry, floating objects, wrong positioning, clipping, inverted faces, color mismatches, any visual artifact
- If even the slightest improvement can be made, make it — don't ship something you know is slightly off
- Only after screenshots pass inspection: commit and deploy
- Windows/doors visible and flush with wall surface (not floating, not z-fighting)
- All 4 wall faces have windows
- Roads have centered yellow lines
- No missing walls or faces
- Buildings sit on grass, not floating
- No geometry overlaps or clipping
- Shadows look correct (not pitch black, not missing)
- Collapsed corners look natural, not like bugs
- Camera angles show buildings properly (not top-down blobs)