Imported from JavierTSalas/three-game-template (
AGENTS.md). Install upstream withnpx skills add JavierTSalas/three-game-template. Copyright stays with the author.
AGENTS.md
What this is
GAME_TITLE — a mobile browser game built on three-game-engine v0.10
(bundles three 0.168 + rapier3d-compat 0.11 + three-mesh-ui). Webpack build, DOM overlays for
menus/controls, procedural WebAudio, PWA-installable, deployed on Vercel (push main → prod).
Born from three-game-template; the shipped "game" is a platformer sandbox (roll, hop, dash)
you replace with your mechanic.
Viewport/fullscreen/PWA: docs/full-screen-pwa.md (one-ruler rule, pitfall table,
cache-busting — read before touching canvas sizing, overlays, or the service worker).
Models/conversion: docs/asset-pipeline.md (text-to-3D, FBX→GLB, black-model triage,
engine-scene reuse — read before importing any 3D asset).
Run / verify
npm run dev # webpack-dev-server :8180, LAN-exposed for phones
npm test # node --test → logic.test.js (pure math only)
npm run build # → dist/
Verify feel by driving the real game in a browser (Playwright/DevTools): pointer events on
#stickZone, keyboard WASD/arrows; window.game / window.__state / window.__director
are exposed for introspection. For every gameplay mode and minigame, exercise the complete
input set with touch/pointer controls in both mobile portrait and landscape viewports, then
once with its desktop keyboard/mouse path. Judge motion by driving it, not from a single frame.
"Something's clipped / there's a scrollbar" → run the debug snippet in
docs/full-screen-pwa.md before touching code.
Architecture
index.js— composition root: splash FIRST (before any engine work), Game boot (own canvas,wsadMovement, pixelRatio cap), class registration, per-framefitCanvas(), menu-orbit camera, out-of-world respawn, REFRESH-APP cache nuke, error screen. Event→juice wiring is registered ONCE withparticles.current/righolders — restart must not stack listeners.logic.js— ALL tuning (TUNE) + pure math, node-tested inlogic.test.js. Change tuning here, never inline. Add your game's math here first, with tests.scripts/state.js— single shared mutable state (phase/paused/camYaw/input edges);scripts/events.js— tiny pub/sub. Events in use:runstart, hop, dash, bump, win, lose.scripts/player.js— the ball-guy: per-frame impulses (NEVER rapieraddForce), joystick + WASD + arrow fallback, hop, dash burst, squash spring, blob shadow, camera-rig update.scripts/director.js— run lifecycle: phasesboot|menu|ready|playing|won|lost, restart with the loadScene-in-flight guard,won()/lost()for your win conditions, platform spawning viascripts/spawn.js(THE runtime-spawn pattern — see gotchas).scripts/camera.js— follow/zoom/orbit/shake;state.camYawis the yaw authority; built ONCE per session against a player getter (rebuilding stacks pointer listeners).scripts/joystick.js— floating touch stick (double-tap-hold = sprint flag).scripts/audio.js— procedural WebAudio;ensure()needs a user gesture;setMuted()feeds the pause-screen sound toggle.scripts/juice.js— toonify pass + particle pool.scripts/pause.js— Esc/⏸ freeze viagame.gameOptions.disablePhysics(live-checked by the engine loop — nevergame.pause(), its dt explodes on resume) +state.pausedgates. Doubles as the settings page (fullscreen, sound).scripts/cutscene.js— intro flyover cutscene (waypoints scale with bounds, tap/key skip, phaseintro); lines live indata/level.json"intro".scripts/hints.js— first-run tutorial: one hint at a time, cleared by DOING the thing, localStorage-tracked. Both are how the "explain the game / teach every mechanic" requirements are met — fill them in.scripts/splash.js— instant 2D canvas splash; module-top start; menu reveal awaitssplash.done, cover drops viasplash.lift()only when the world is ready.scripts/terrain.js— ground/fence/sun/sky/fog theme.scripts/platform.js— hop boxes.data/level.json— bounds/spawn/placements. Content lives in data, not code.- Engine JSON prefabs in
game_objects/, scene inscenes/main.json, manifestgame.json.
⚠️ Engine gotchas (verified against three-game-engine v0.10 — re-verify on upgrades)
- JSON schema is components-based:
{"components":[{"type":"rigidBody","rigidBodyType":"dynamic","colliders":[...]}]}. The engine's own docs folder shows an older"rigidBody":{}/"models":[]form — WRONG, don't copy it. scene.activeis never set true by the engine, soaddGameObjectnever auto-loads runtime spawns: after constructing one you MUST callobj.load().then(() => obj.afterLoaded()). AND:registerGameObjectClassesonly applies to scene-JSON loads — a runtimenew GameObject(...)bypasses the registry, so your subclass'safterLoaded(meshes!) silently never runs; construct the subclass itself.scripts/spawn.jsencodes both.game.rendererexists only aftergame.play()(created in async_init). Attachgame.renderer.options.beforeRenderafter play resolves.- Rapier
addForceis persistent (accumulates every frame untilresetForces). Use per-frameapplyImpulse(force * dt)for driving. - rapier3d-compat 0.11 has NO
intersectionPairsWith(newer API). Overlap queries =world.intersectionsWithShape(pos, rot, shape, cb)— the callback MUSTreturn trueto keep iterating, and never mutate the world mid-query (collect, then act). Scene.setFog(null)warnsInvalid hex color #00000:typeof null === 'object'routes null into the engine's defaults branch, whose color literal is malformed. Declaring a valid"fog"in each scene JSON bypasses it (terrain re-fogs per theme right after anyway).- GLB
scene.clone(true)SHARES materials across clones — mutating a material (emissive, opacity) hits every instance of that model. Idempotent one-time fixes (tint, alpha clamp) are fine; per-instance cues must transform the group (jiggle/sway), never the material. - MUST pass
inputOptions: { wsadMovement: true }— W/A are dead without it (inside the wsadMovement branch in InputManager). Arrow keys are dead in the engine REGARDLESS:KeyboardHandlerstoresevent.key.toLowerCase()butInputManagerchecksisKeyDown('ArrowUp')mixed-case → never matches.player.jsreads'arrowup'etc. fromkeyboardHandlerdirectly as fallback — query that store lowercase only. readVerticalAxis(): forward = -1. Our convention is{x:+right, z:+forward}, so negate it.- Never
setupFullScreenCanvas: true(wipesdocument.body= kills the DOM overlays). We pass our own canvas + our own per-framefitCanvas(). game.loadScene()throws if a load is in flight — restart paths guard withdirector.restarting.- Import THREE/RAPIER only from
'three-game-engine're-exports (never separate npm deps). - InstancedMesh:
THREE.DynamicDrawUsage, neverTHREE.DynamicDraw(undefined in r160+ → silently invisible mesh). - Rapier colliders can't resize in place: changing size = removeCollider + createCollider.
- Engine tags are set synchronously in the constructor but
afterLoadedruns async — guardisLoaded()in anything that iterates tagged objects. - Loader note for GLB props: skip rigged/skinned GLBs on a plain-clone pipeline (breaks
skeletons + bounding math); missing/broken GLB should fall back to a primitive so the game
always runs. See
docs/asset-pipeline.md. getWorldPos()returns a REUSED Vector3 — copy scalars before iterating over it.
Conventions
- Tuning: change
TUNEinlogic.js, never inline magic numbers. - Content in
data/*.json, math inlogic.js(tested), wiring inindex.js, feel inscripts/— keep the layers. ponytail:comments mark deliberate ceilings (shortcut + upgrade path).- Test gate:
npm testgreen before pushingmain(Vercel deploysmain).
Game development requirements (non-negotiable)
- Are you in the template? Run
git remote -vfirst. If origin isthree-game-template, this is the TEMPLATE — never build a specific game here. Birth the game first:gh repo create <game> --template JavierTSalas/three-game-template --private --clone, work there, and create a NEW Vercel project imported from THAT repo. Never runvercel link/vercel deploy/vercel git connectfrom the template checkout — that attaches a game-named Vercel project to the template repo, and every future template push will overwrite the game's production URL (this happened once; don't repeat it). A PreToolUse hook (.Codex/settings.json→tools/guard-hook.mjs) also BLOCKSvercel deploy/link/git connectfrom this checkout as a backstop; read-only vercel commands pass. - Plan before code. Before implementing any game idea (new game, new mode, or a major
mechanic), write a design artifact first — copy
docs/prd-template.mdtodocs/<game-name>-prd.mdand fill every section (pitch, core loop, mechanics with their teach-plan, controls, win/lose, tutorialization, scope cuts). Get the user's sign-off on the plan, then implement. - Invent the fun when the pitch is brief. A short or vague request is permission to make a strong, coherent creative choice, not to preserve the generic sandbox or ask the user to design the game. Autonomously choose the strongest premise-specific core loop, write it into the PRD, and present that complete design for sign-off. Moving, hopping, dashing, collecting, or merely surviving are controls or objectives, not a finished loop by themselves. The loop must create a repeated meaningful decision, readable risk/reward, escalating pressure, a clear failure/recovery hook, and room for mastery that makes another run appealing.
- Make rewards earned and layered. Design feedback at three timescales: immediate response to each skilled action, a 10–30 second chain/multiplier/near-miss or rising-stakes cycle, and a whole-run arc with changing challenges, milestones, and a persistent goal. Tie animation, sound, particles, shake, hit-stop, scoring, and UI emphasis to real player actions and state changes. High feedback should come from anticipation, precision, risk, and payoff — never arbitrary popup spam, meaningless currencies, passive rewards, or idle dark patterns. Fill the PRD's core-loop and reward-cadence tables before implementation.
- Author the world; never ship the fallback room. The starter checkerboard, four-wall
boundary, default bounds, and platform rows are scaffolding, not a neutral finished level.
Every game must replace that spatial identity with a topology and silhouette derived from
its premise and core loop. Before implementation, fill the PRD's "World / level identity"
section: choose the boundary fiction, traversal pattern, and at least one signature spatial
landmark, then explain how they support play. If the user's idea is vague, make a specific,
coherent choice instead of preserving the starter layout. A square or rectangular room is
allowed only when it is an intentional authored choice and the PRD explains why it serves
the game; recoloring or decorating the fallback room is not enough. Update
scripts/terrain.js,data/level.jsonbounds/spawn/placements, and camera or intro framing as needed so the playable space, visuals, and boundary logic agree. - Input parity. Every required gameplay action in the main game, every minigame, and every interactive overlay must be usable on both (1) a mobile touchscreen in portrait and landscape with no physical keyboard and (2) desktop with keyboard and/or mouse/pointer as appropriate to the action. Where keyboard and pointer both fit naturally, support both. A direct touch gesture counts; otherwise provide visible on-screen controls. Never ship a keyboard-only mode, rely on hover/right-click, or assume a mobile player has a hardware keyboard. Controls may adapt by input capability and do not all need to be visible at once. Record both paths in the PRD and verify both in the real game; if a mechanic truly cannot support one path, stop and get explicit user sign-off before coding.
- Intro cutscene. Every game must open with an intro cutscene that explains the premise
and what the player is trying to do. The scaffold ships:
scripts/cutscene.jsplays a skippable flyover after PLAY using the"intro"lines indata/level.json— replace the placeholder lines with your game's story; extend the waypoints/cards if it needs more. It opens with a "presents" card: the birth author (level.studio, "A GAME BY …") or, if none, a studio name generated from the title (logic.presenterLine/studioName) — a unique, stable signature per game. - Teach every mechanic. Whenever a game mechanic exists (hop, dash, or whatever your game
adds), it must be explained to the player in-game. The scaffold ships:
scripts/hints.jsshows one instruction at a time and advances only when the player DOES it (localStorage once-per-install). Add a step (or a contextual hint) for every mechanic you add — the PRD's mechanics table must say which.
Growing a game from the skeleton
Score/HUD: add fields to state, a DOM overlay in index.html, update it in the
beforeRender hook. Win/lose: call director.won()/lost() from your rules, listen for
win/lose events for screens/sound (jingles are already wired). New object types: engine
prefab in game_objects/ + a class registered in index.js + rows in data/level.json,
spawned via spawnPrefab. New sounds: bind events in audio.js. New math: logic.js + a
test. Real 3D models: docs/asset-pipeline.md, drop GLBs in models/ (webpack copies it).