
Threejs Gameplay Systems
- 1.2k installs
- 1.2k repo stars
- Updated July 16, 2026
- majidmanzarpour/threejs-game-skills
Helps with ai & agent building tasks.
About
threejs-gameplay-systems is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- threejs-gameplay-systems
- AI & Agent Building
- AI-coding skill
Threejs Gameplay Systems by the numbers
- 1,180 all-time installs (skills.sh)
- +137 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #938 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/majidmanzarpour/threejs-game-skills --skill threejs-gameplay-systemsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.2k |
|---|---|
| repo stars | ★ 1.2k |
| Last updated | July 16, 2026 |
| Repository | majidmanzarpour/threejs-game-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Three.js Gameplay Systems
Purpose
Create or evolve a playable browser game loop with clear ownership, responsive controls, deterministic update order, and verified player-facing behavior.
Use When
Starting a new game, repairing a weak prototype, adding mechanics/entities, designing architecture, tuning camera/controls, implementing rules/objectives, or improving game feel.
Workflow
Load references/gameplay-workflows.md as the first action when the task includes first playable setup, architecture, mechanics, entities, input, camera, collision/physics, scoring, objectives, feedback, or feel tuning.
Load references/physics-engine-selection.md before adding or changing physics, collision-heavy gameplay, vehicle movement, rolling balls, mini-golf, pool/snooker, pinball, rigid-body puzzles, character controllers, sensors, high-speed projectiles, moving platforms, or physics QA. Track both references in a reference ledger with yes/no, path, and failure reason. Do not mark the gameplay phase complete while a required reference is skipped.
Load references/checklists/new-game-definition-of-done.md before claiming a new game or first playable slice is complete.
Load references/checklists/endless-runner-premium-quality.md for endless runner work.
Load references/prompt-templates.md only when the user asks for reusable prompts, starter prompts, or a task template.
Load threejs-audio-generator when implementing real SFX, ambience, UI sounds, voice/TTS, or audio cleanup beyond simple placeholder hooks. Gameplay code should emit audio events; the audio skill should generate or process the actual assets and define the runtime audio matrix.
1. Inspect project structure, scripts, dependencies, current loop, input, camera, entities, state, UI, and diagnostics. 2. Define the one-sentence playable loop: verb, objective, feedback, fail/retry. 3. Choose small architecture boundaries: core, game, entities, systems, assets, ui, tests. 4. Implement mechanics in playable increments: input, state, entity, collision/physics, feedback, HUD/audio hook, diagnostics. 5. Tune feel: movement, acceleration, camera follow/FOV/shake, impact, cooldowns, difficulty, restart loop. 6. Keep hot paths allocation-light and update order explicit. 7. Verify with build, browser, screenshot, canvas pixels, console/page errors, and one real input path.
Packaged Scaffold
Use the bundled scaffold when starting a new project or when the user asks for a starter game:
python3 <this-skill-dir>/scripts/create_threejs_game.py ./my-gameThe script copies assets/threejs-vite-game/, rewrites the project name in package.json and package-lock.json, and keeps generated games self-contained with their own visual test and canvas-inspection script. Use --force only when the target directory may be overwritten.
Library Guidance
- Use TypeScript, Vite, Three.js modules.
- Custom collision for simple arcade triggers and pickups.
- Rapier is the default robust physics engine for serious Three.js browser games with rigid bodies, sensors, balls, ramps, many contacts, or high-speed collisions.
- Use
cannon-esonly as a lightweight JS fallback for small/simple rigid-body scenes. - Use custom collision when authored arcade feel is more important than simulation.
lil-guifor live-tuned constants when useful.- Web Audio for runtime playback and procedural feedback;
threejs-audio-generatorfor generated game audio assets.
Common Failure Modes
- Static demo instead of playable loop.
- Mechanic compiles but cannot be triggered by real input.
- Camera/controls feel delayed or hide the next decision.
- State changes do not drive UI/audio/VFX.
- Architecture abstractions appear before mechanics need them.
Final Response
Report the reference ledger, gameplay checklist outcome, behavior, controls, changed files, architecture choices, tuned values, verification evidence, artifacts, and remaining edge cases.
interface:
display_name: "Three.js Gameplay Systems"
short_description: "Build playable Three.js systems"
default_prompt: "Create or improve this Three.js game's playable loop, architecture, mechanics, controls, camera, and feel with browser verification. First load the gameplay workflows reference, report the reference ledger, and include the gameplay checklist outcome."
node_modules/
dist/
test-results/
artifacts/
*.log
.DS_Store
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#111312" />
<title>Three.js Vite Game</title>
</head>
<body>
<main id="app">
<canvas id="game-canvas" aria-label="Playable Three.js game canvas"></canvas>
<section id="hud" aria-live="polite">
<div class="hud-metric">
<span>Relays</span>
<strong><span id="score-value">0</span>/<span id="target-value">8</span></strong>
</div>
<div class="hud-metric">
<span>Time</span>
<strong id="timer-value">00:00</strong>
</div>
<div id="status-line">Collect relays</div>
</section>
<div id="touch-controls" aria-hidden="true">
<div id="touch-stick">
<div id="touch-knob"></div>
</div>
<button id="dash-button" type="button">Dash</button>
</div>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
{
"name": "threejs-vite-game",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "threejs-vite-game",
"version": "0.1.0",
"dependencies": {
"lil-gui": "^0.21.0",
"three": "^0.184.0"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@types/node": "^25.9.0",
"@types/pngjs": "^6.0.5",
"@types/three": "^0.184.1",
"pngjs": "^7.0.0",
"typescript": "^6.0.3",
"vite": "^8.0.13"
}
},
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@oxc-project/types": {
"version": "0.130.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/node": {
"version": "25.9.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz",
"integrity": "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/pngjs": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz",
"integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.184.1",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz",
"integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"fflate": "~0.8.2",
"meshoptimizer": "~1.1.1"
}
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"dev": true,
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lil-gui": {
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/lil-gui/-/lil-gui-0.21.0.tgz",
"integrity": "sha512-tpvxN7v1GvE/Tv+GRopfOp0W7fVEjF4PltkuX8vOCIfim22rD1ztvfkoEMcv9lzQeuNUSeIrUmUjBwmlW/oUew==",
"license": "MIT"
},
"node_modules/meshoptimizer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/rolldown": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.130.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.1",
"@rolldown/binding-darwin-arm64": "1.0.1",
"@rolldown/binding-darwin-x64": "1.0.1",
"@rolldown/binding-freebsd-x64": "1.0.1",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
"@rolldown/binding-linux-arm64-gnu": "1.0.1",
"@rolldown/binding-linux-arm64-musl": "1.0.1",
"@rolldown/binding-linux-ppc64-gnu": "1.0.1",
"@rolldown/binding-linux-s390x-gnu": "1.0.1",
"@rolldown/binding-linux-x64-gnu": "1.0.1",
"@rolldown/binding-linux-x64-musl": "1.0.1",
"@rolldown/binding-openharmony-arm64": "1.0.1",
"@rolldown/binding-wasm32-wasi": "1.0.1",
"@rolldown/binding-win32-arm64-msvc": "1.0.1",
"@rolldown/binding-win32-x64-msvc": "1.0.1"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/three": {
"version": "0.184.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
"version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.14",
"rolldown": "1.0.1",
"tinyglobby": "^0.2.16"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/vite/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
}
}
}
{
"name": "threejs-vite-game",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc && vite build",
"preview": "vite preview --host 127.0.0.1",
"test": "playwright test",
"verify:visual": "playwright test tests/visual.spec.ts",
"inspect:canvas": "node scripts/inspect-threejs-canvas.mjs"
},
"dependencies": {
"lil-gui": "^0.21.0",
"three": "^0.184.0"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@types/node": "^25.9.0",
"@types/pngjs": "^6.0.5",
"@types/three": "^0.184.1",
"pngjs": "^7.0.0",
"typescript": "^6.0.3",
"vite": "^8.0.13"
}
}
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: {
timeout: 5_000,
},
use: {
baseURL: 'http://127.0.0.1:5188',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
webServer: {
command: 'npm run dev',
url: 'http://127.0.0.1:5188',
reuseExistingServer: false,
timeout: 20_000,
},
projects: [
{
name: 'desktop-chrome',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1280, height: 720 },
},
},
{
name: 'mobile-safari',
use: {
...devices['iPhone 13'],
},
},
],
});
#!/usr/bin/env node
import { chromium, devices } from '@playwright/test';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { PNG } from 'pngjs';
function parseArgs(argv) {
const args = {
url: 'http://127.0.0.1:5188',
out: 'artifacts/canvas-inspection',
mobile: false,
wait: 750,
};
for (let i = 0; i < argv.length; i += 1) {
const value = argv[i];
if (value === '--url') args.url = argv[++i];
else if (value === '--out') args.out = argv[++i];
else if (value === '--mobile') args.mobile = true;
else if (value === '--wait') args.wait = Number(argv[++i]);
else if (value === '-h' || value === '--help') {
console.log('Usage: inspect-threejs-canvas.mjs [--url URL] [--out DIR] [--mobile] [--wait MS]');
process.exit(0);
} else {
throw new Error(`Unknown argument: ${value}`);
}
}
return args;
}
async function sampleCanvas(page) {
const locator = page.locator('canvas').first();
const rect = await locator.boundingBox();
if (!rect || rect.width < 32 || rect.height < 32) {
return { ok: false, reason: 'canvas-too-small', rect };
}
const buffer = await locator.screenshot();
const png = PNG.sync.read(buffer);
let min = 255;
let max = 0;
let alphaPixels = 0;
const colors = new Set();
const stride = Math.max(1, Math.floor((png.width * png.height) / 4096));
for (let pixel = 0; pixel < png.width * png.height; pixel += stride) {
const offset = pixel * 4;
const r = png.data[offset];
const g = png.data[offset + 1];
const b = png.data[offset + 2];
const a = png.data[offset + 3];
min = Math.min(min, r, g, b);
max = Math.max(max, r, g, b);
if (a > 0) alphaPixels += 1;
colors.add(`${r >> 4},${g >> 4},${b >> 4},${a >> 6}`);
}
const variance = max - min;
const diagnostics = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
return {
drawingBuffer: canvas
? { width: canvas.width, height: canvas.height }
: null,
game: window.__THREE_GAME_DIAGNOSTICS__ ?? null,
};
});
const ok = alphaPixels > 256 && (variance > 8 || colors.size > 3);
return {
ok,
reason: ok ? 'nonblank' : 'low-variance',
rect,
drawingBuffer: diagnostics.drawingBuffer,
alphaPixels,
variance,
colorBuckets: colors.size,
diagnostics: diagnostics.game,
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
await mkdir(args.out, { recursive: true });
const browser = await chromium.launch();
const context = await browser.newContext(args.mobile
? { ...devices['iPhone 13'], userAgent: undefined }
: { viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 });
const page = await context.newPage();
const consoleErrors = [];
const pageErrors = [];
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text());
});
page.on('pageerror', (error) => pageErrors.push(error.message));
await page.goto(args.url, { waitUntil: 'networkidle' });
await page.waitForSelector('canvas', { state: 'visible', timeout: 10_000 });
await page.waitForTimeout(args.wait);
const result = await sampleCanvas(page);
const screenshotPath = path.join(args.out, args.mobile ? 'mobile.png' : 'desktop.png');
await page.screenshot({ path: screenshotPath, fullPage: true });
const report = {
url: args.url,
mode: args.mobile ? 'mobile' : 'desktop',
screenshotPath,
result,
consoleErrors,
pageErrors,
};
await writeFile(path.join(args.out, args.mobile ? 'mobile.json' : 'desktop.json'), `${JSON.stringify(report, null, 2)}\n`);
await browser.close();
console.log(JSON.stringify(report, null, 2));
if (!result.ok || consoleErrors.length > 0 || pageErrors.length > 0) {
process.exit(1);
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
import * as THREE from 'three';
type PointerState = {
active: boolean;
id: number | null;
centerX: number;
centerY: number;
radius: number;
};
export class InputController {
private readonly keys = new Set<string>();
private readonly pointer = new THREE.Vector2();
private readonly keyVector = new THREE.Vector2();
private readonly pointerState: PointerState = {
active: false,
id: null,
centerX: 0,
centerY: 0,
radius: 1,
};
private dashDown = false;
private readonly onKeyDown = (event: KeyboardEvent) => {
this.keys.add(event.code);
if (event.code === 'Space' || event.code === 'ShiftLeft' || event.code === 'ShiftRight') {
this.dashDown = true;
}
};
private readonly onKeyUp = (event: KeyboardEvent) => {
this.keys.delete(event.code);
if (event.code === 'Space' || event.code === 'ShiftLeft' || event.code === 'ShiftRight') {
this.dashDown = false;
}
};
private readonly onStickDown = (event: PointerEvent) => {
event.preventDefault();
const rect = this.stick.getBoundingClientRect();
this.pointerState.active = true;
this.pointerState.id = event.pointerId;
this.pointerState.centerX = rect.left + rect.width / 2;
this.pointerState.centerY = rect.top + rect.height / 2;
this.pointerState.radius = rect.width * 0.42;
try {
this.stick.setPointerCapture(event.pointerId);
} catch {
// Synthetic test events do not always have a capturable pointer id.
}
this.updatePointer(event.clientX, event.clientY);
};
private readonly onStickMove = (event: PointerEvent) => {
if (!this.pointerState.active || event.pointerId !== this.pointerState.id) return;
event.preventDefault();
this.updatePointer(event.clientX, event.clientY);
};
private readonly onStickUp = (event: PointerEvent) => {
if (event.pointerId !== this.pointerState.id) return;
event.preventDefault();
this.pointerState.active = false;
this.pointerState.id = null;
this.pointer.set(0, 0);
this.updateKnob();
};
private readonly onDashDown = (event: PointerEvent) => {
event.preventDefault();
this.dashDown = true;
};
private readonly onDashUp = (event: PointerEvent) => {
event.preventDefault();
this.dashDown = false;
};
constructor(
private readonly stick: HTMLElement,
private readonly knob: HTMLElement,
private readonly dashButton: HTMLElement,
) {
window.addEventListener('keydown', this.onKeyDown);
window.addEventListener('keyup', this.onKeyUp);
this.stick.addEventListener('pointerdown', this.onStickDown);
this.stick.addEventListener('pointermove', this.onStickMove);
this.stick.addEventListener('pointerup', this.onStickUp);
this.stick.addEventListener('pointercancel', this.onStickUp);
this.dashButton.addEventListener('pointerdown', this.onDashDown);
this.dashButton.addEventListener('pointerup', this.onDashUp);
this.dashButton.addEventListener('pointercancel', this.onDashUp);
this.dashButton.addEventListener('pointerleave', this.onDashUp);
}
readMovement(target: THREE.Vector2): THREE.Vector2 {
this.keyVector.set(0, 0);
if (this.keys.has('KeyA') || this.keys.has('ArrowLeft')) this.keyVector.x -= 1;
if (this.keys.has('KeyD') || this.keys.has('ArrowRight')) this.keyVector.x += 1;
if (this.keys.has('KeyW') || this.keys.has('ArrowUp')) this.keyVector.y -= 1;
if (this.keys.has('KeyS') || this.keys.has('ArrowDown')) this.keyVector.y += 1;
target.copy(this.keyVector).add(this.pointer);
if (target.lengthSq() > 1) target.normalize();
return target;
}
isDashHeld(): boolean {
return this.dashDown;
}
dispose(): void {
window.removeEventListener('keydown', this.onKeyDown);
window.removeEventListener('keyup', this.onKeyUp);
this.stick.removeEventListener('pointerdown', this.onStickDown);
this.stick.removeEventListener('pointermove', this.onStickMove);
this.stick.removeEventListener('pointerup', this.onStickUp);
this.stick.removeEventListener('pointercancel', this.onStickUp);
this.dashButton.removeEventListener('pointerdown', this.onDashDown);
this.dashButton.removeEventListener('pointerup', this.onDashUp);
this.dashButton.removeEventListener('pointercancel', this.onDashUp);
this.dashButton.removeEventListener('pointerleave', this.onDashUp);
}
private updatePointer(clientX: number, clientY: number): void {
const dx = clientX - this.pointerState.centerX;
const dy = clientY - this.pointerState.centerY;
this.pointer.set(dx / this.pointerState.radius, dy / this.pointerState.radius);
if (this.pointer.lengthSq() > 1) this.pointer.normalize();
this.updateKnob();
}
private updateKnob(): void {
const distance = 38;
this.knob.style.transform = `translate(calc(-50% + ${this.pointer.x * distance}px), calc(-50% + ${this.pointer.y * distance}px))`;
}
}
export class Loop {
private frameId = 0;
private lastTime = 0;
private running = false;
constructor(
private readonly update: (deltaSeconds: number, elapsedSeconds: number) => void,
private readonly render: () => void,
) {}
start(): void {
if (this.running) return;
this.running = true;
this.lastTime = performance.now();
this.frameId = requestAnimationFrame(this.tick);
}
stop(): void {
this.running = false;
cancelAnimationFrame(this.frameId);
}
private readonly tick = (time: number) => {
if (!this.running) return;
const deltaSeconds = Math.min((time - this.lastTime) / 1000, 0.05);
this.lastTime = time;
this.update(deltaSeconds, time / 1000);
this.render();
this.frameId = requestAnimationFrame(this.tick);
};
}
import * as THREE from 'three';
export function createRenderer(canvas: HTMLCanvasElement): THREE.WebGLRenderer {
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: false,
powerPreference: 'high-performance',
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
return renderer;
}
export function resizeRenderer(
renderer: THREE.WebGLRenderer,
camera: THREE.PerspectiveCamera,
maxDpr = 2,
): boolean {
const canvas = renderer.domElement;
const width = Math.max(1, Math.floor(canvas.clientWidth));
const height = Math.max(1, Math.floor(canvas.clientHeight));
const dpr = Math.min(window.devicePixelRatio || 1, maxDpr);
const bufferWidth = Math.floor(width * dpr);
const bufferHeight = Math.floor(height * dpr);
const needsResize = canvas.width !== bufferWidth || canvas.height !== bufferHeight;
if (needsResize) {
renderer.setPixelRatio(dpr);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
return needsResize;
}
import * as THREE from 'three';
export class Pickup {
readonly group = new THREE.Group();
readonly radius = 0.62;
active = true;
private readonly coreGeometry = new THREE.IcosahedronGeometry(0.42, 1);
private readonly ringGeometry = new THREE.TorusGeometry(0.58, 0.028, 8, 32);
private readonly coreMaterial = new THREE.MeshStandardMaterial({
color: '#48baa7',
emissive: '#0f5249',
emissiveIntensity: 0.8,
roughness: 0.28,
metalness: 0.1,
});
private readonly ringMaterial = new THREE.MeshBasicMaterial({
color: '#f6f1df',
});
constructor(
readonly index: number,
position: THREE.Vector3,
) {
const core = new THREE.Mesh(this.coreGeometry, this.coreMaterial);
core.castShadow = true;
this.group.add(core);
const ring = new THREE.Mesh(this.ringGeometry, this.ringMaterial);
ring.rotation.x = Math.PI / 2;
this.group.add(ring);
this.group.position.copy(position);
}
update(delta: number, elapsed: number): void {
if (!this.active) return;
this.group.rotation.y += delta * 1.8;
this.group.children[0].rotation.x -= delta * 1.2;
this.group.position.y = 0.78 + Math.sin(elapsed * 2.6 + this.index) * 0.16;
}
collect(): void {
this.active = false;
this.group.visible = false;
}
dispose(): void {
this.coreGeometry.dispose();
this.ringGeometry.dispose();
this.coreMaterial.dispose();
this.ringMaterial.dispose();
}
}
import * as THREE from 'three';
import type { InputController } from '../core/InputController';
export type PlayerTuning = {
speed: number;
dashMultiplier: number;
acceleration: number;
};
export type ArenaBounds = {
halfWidth: number;
halfDepth: number;
};
export class Player {
readonly group = new THREE.Group();
readonly velocity = new THREE.Vector3();
private readonly move = new THREE.Vector2();
private readonly targetVelocity = new THREE.Vector3();
private readonly bodyMaterial = new THREE.MeshStandardMaterial({
color: '#f5ba49',
roughness: 0.48,
metalness: 0.12,
});
private readonly accentMaterial = new THREE.MeshStandardMaterial({
color: '#48baa7',
roughness: 0.32,
metalness: 0.18,
emissive: '#123f39',
emissiveIntensity: 0.35,
});
private readonly bodyGeometry = new THREE.CapsuleGeometry(0.38, 0.58, 6, 12);
private readonly noseGeometry = new THREE.ConeGeometry(0.22, 0.5, 4);
constructor() {
const body = new THREE.Mesh(this.bodyGeometry, this.bodyMaterial);
body.castShadow = true;
body.receiveShadow = true;
body.position.y = 0.68;
this.group.add(body);
const nose = new THREE.Mesh(this.noseGeometry, this.accentMaterial);
nose.castShadow = true;
nose.position.set(0, 0.68, -0.58);
nose.rotation.x = Math.PI / 2;
this.group.add(nose);
}
update(delta: number, elapsed: number, input: InputController, tuning: PlayerTuning, bounds: ArenaBounds): void {
input.readMovement(this.move);
const dash = input.isDashHeld() ? tuning.dashMultiplier : 1;
this.targetVelocity.set(this.move.x, 0, this.move.y).multiplyScalar(tuning.speed * dash);
const smoothing = 1 - Math.exp(-tuning.acceleration * delta);
this.velocity.lerp(this.targetVelocity, smoothing);
this.group.position.addScaledVector(this.velocity, delta);
this.group.position.x = THREE.MathUtils.clamp(this.group.position.x, -bounds.halfWidth + 0.8, bounds.halfWidth - 0.8);
this.group.position.z = THREE.MathUtils.clamp(this.group.position.z, -bounds.halfDepth + 0.8, bounds.halfDepth - 0.8);
if (this.velocity.lengthSq() > 0.001) {
this.group.rotation.y = Math.atan2(this.velocity.x, -this.velocity.z);
}
this.group.position.y = 0.06 + Math.sin(elapsed * 9) * Math.min(this.velocity.length() / 40, 0.08);
}
dispose(): void {
this.bodyGeometry.dispose();
this.noseGeometry.dispose();
this.bodyMaterial.dispose();
this.accentMaterial.dispose();
}
}
import * as THREE from 'three';
import { InputController } from '../core/InputController';
import { Loop } from '../core/Loop';
import { createRenderer, resizeRenderer } from '../core/Renderer';
import { Pickup } from '../entities/Pickup';
import { Player, type ArenaBounds } from '../entities/Player';
import { AudioSystem } from '../systems/AudioSystem';
import { CameraRig } from '../systems/CameraRig';
import { CollisionSystem } from '../systems/CollisionSystem';
import { DebugTools, type DebugTuning } from '../systems/DebugTools';
import { Hud } from '../systems/Hud';
const ARENA: ArenaBounds = {
halfWidth: 11,
halfDepth: 7,
};
export class Game {
private readonly renderer: THREE.WebGLRenderer;
private readonly scene = new THREE.Scene();
private readonly camera = new THREE.PerspectiveCamera(48, 1, 0.1, 80);
private readonly input: InputController;
private readonly player = new Player();
private readonly pickups: Pickup[] = [];
private readonly collision = new CollisionSystem();
private readonly audio = new AudioSystem();
private readonly hud = new Hud();
private readonly cameraRig = new CameraRig(this.camera);
private readonly loop = new Loop(
(delta, elapsed) => this.update(delta, elapsed),
() => this.render(),
);
private readonly tuning: DebugTuning = {
speed: 5.8,
dashMultiplier: 1.75,
acceleration: 13,
cameraLag: 0.16,
exposure: 1.05,
maxDpr: 2,
};
private readonly debugTools: DebugTools;
private frame = 0;
private score = 0;
private elapsed = 0;
private complete = false;
constructor(private readonly canvas: HTMLCanvasElement) {
this.renderer = createRenderer(canvas);
this.renderer.toneMappingExposure = this.tuning.exposure;
const stick = this.getElement('#touch-stick');
const knob = this.getElement('#touch-knob');
const dashButton = this.getElement('#dash-button');
this.input = new InputController(stick, knob, dashButton);
this.debugTools = new DebugTools(this.tuning, () => {
this.renderer.toneMappingExposure = this.tuning.exposure;
resizeRenderer(this.renderer, this.camera, this.tuning.maxDpr);
});
this.createScene();
this.hud.setTarget(this.pickups.length);
this.cameraRig.snapTo(this.player.group.position);
resizeRenderer(this.renderer, this.camera, this.tuning.maxDpr);
this.publishDiagnostics();
}
start(): void {
this.loop.start();
}
dispose(): void {
this.loop.stop();
this.input.dispose();
this.audio.dispose();
this.debugTools.dispose();
for (const pickup of this.pickups) pickup.dispose();
this.player.dispose();
this.renderer.dispose();
window.__THREE_GAME_DIAGNOSTICS__ = undefined;
}
private update(delta: number, elapsed: number): void {
this.frame += 1;
if (!this.complete) this.elapsed += delta;
resizeRenderer(this.renderer, this.camera, this.tuning.maxDpr);
this.player.update(delta, elapsed, this.input, this.tuning, ARENA);
for (const pickup of this.pickups) {
pickup.update(delta, elapsed);
}
const collected = this.collision.collectPickups(this.player.group.position, this.pickups, 0.55);
for (const pickup of collected) {
this.score += 1;
this.audio.pickup(pickup.index);
this.hud.flashPickup();
}
if (this.score >= this.pickups.length) {
this.complete = true;
}
this.cameraRig.update(delta, this.player.group.position, this.tuning.cameraLag);
this.hud.update(this.score, this.pickups.length, this.elapsed, this.complete);
this.publishDiagnostics();
}
private render(): void {
this.renderer.render(this.scene, this.camera);
}
private createScene(): void {
this.scene.background = new THREE.Color('#151713');
this.scene.fog = new THREE.Fog('#151713', 20, 44);
const hemisphere = new THREE.HemisphereLight('#f6f1df', '#2b322d', 1.7);
this.scene.add(hemisphere);
const sun = new THREE.DirectionalLight('#fff1bf', 2.6);
sun.position.set(-5, 9, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 0.5;
sun.shadow.camera.far = 30;
sun.shadow.camera.left = -14;
sun.shadow.camera.right = 14;
sun.shadow.camera.top = 12;
sun.shadow.camera.bottom = -12;
this.scene.add(sun);
this.scene.add(this.createArena());
this.scene.add(this.player.group);
this.createPickups();
}
private createArena(): THREE.Group {
const arena = new THREE.Group();
const floorTexture = this.createFloorTexture();
floorTexture.wrapS = THREE.RepeatWrapping;
floorTexture.wrapT = THREE.RepeatWrapping;
floorTexture.repeat.set(ARENA.halfWidth / 2, ARENA.halfDepth / 2);
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(ARENA.halfWidth * 2, ARENA.halfDepth * 2, 1, 1),
new THREE.MeshStandardMaterial({
color: '#2a2c25',
map: floorTexture,
roughness: 0.72,
metalness: 0.02,
}),
);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
arena.add(floor);
const railMaterial = new THREE.MeshStandardMaterial({
color: '#d94f35',
roughness: 0.52,
metalness: 0.08,
});
const longRailGeometry = new THREE.BoxGeometry(ARENA.halfWidth * 2 + 1, 0.55, 0.42);
const shortRailGeometry = new THREE.BoxGeometry(0.42, 0.55, ARENA.halfDepth * 2 + 1);
const rails = [
new THREE.Mesh(longRailGeometry, railMaterial),
new THREE.Mesh(longRailGeometry, railMaterial),
new THREE.Mesh(shortRailGeometry, railMaterial),
new THREE.Mesh(shortRailGeometry, railMaterial),
];
rails[0].position.set(0, 0.28, -ARENA.halfDepth - 0.24);
rails[1].position.set(0, 0.28, ARENA.halfDepth + 0.24);
rails[2].position.set(-ARENA.halfWidth - 0.24, 0.28, 0);
rails[3].position.set(ARENA.halfWidth + 0.24, 0.28, 0);
for (const rail of rails) {
rail.castShadow = true;
rail.receiveShadow = true;
arena.add(rail);
}
const markerMaterial = new THREE.MeshBasicMaterial({ color: '#f5ba49' });
const markerGeometry = new THREE.RingGeometry(0.68, 0.72, 48);
const centerMarker = new THREE.Mesh(markerGeometry, markerMaterial);
centerMarker.rotation.x = -Math.PI / 2;
centerMarker.position.y = 0.018;
arena.add(centerMarker);
return arena;
}
private createPickups(): void {
const positions = [
[-8, -4],
[-3, -5],
[3, -4.8],
[8, -3],
[-7.5, 3.5],
[-1.5, 4.7],
[4.5, 3.8],
[8.2, 1.4],
];
positions.forEach(([x, z], index) => {
const pickup = new Pickup(index, new THREE.Vector3(x, 0.8, z));
this.pickups.push(pickup);
this.scene.add(pickup.group);
});
}
private createFloorTexture(): THREE.CanvasTexture {
const size = 256;
const textureCanvas = document.createElement('canvas');
textureCanvas.width = size;
textureCanvas.height = size;
const context = textureCanvas.getContext('2d');
if (!context) throw new Error('Could not create floor texture context.');
context.fillStyle = '#282a24';
context.fillRect(0, 0, size, size);
context.strokeStyle = 'rgba(246, 241, 223, 0.08)';
context.lineWidth = 1;
for (let i = 0; i <= size; i += 32) {
context.beginPath();
context.moveTo(i, 0);
context.lineTo(i, size);
context.moveTo(0, i);
context.lineTo(size, i);
context.stroke();
}
context.strokeStyle = 'rgba(245, 186, 73, 0.24)';
context.lineWidth = 2;
context.strokeRect(8, 8, size - 16, size - 16);
const texture = new THREE.CanvasTexture(textureCanvas);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
private publishDiagnostics(): void {
const info = this.renderer.info;
window.__THREE_GAME_DIAGNOSTICS__ = {
frame: this.frame,
elapsed: this.elapsed,
score: this.score,
targetScore: this.pickups.length,
complete: this.complete,
player: {
position: {
x: this.player.group.position.x,
y: this.player.group.position.y,
z: this.player.group.position.z,
},
speed: this.player.velocity.length(),
},
renderer: {
calls: info.render.calls,
triangles: info.render.triangles,
geometries: info.memory.geometries,
textures: info.memory.textures,
},
canvas: {
clientWidth: this.canvas.clientWidth,
clientHeight: this.canvas.clientHeight,
width: this.canvas.width,
height: this.canvas.height,
dpr: Math.min(window.devicePixelRatio || 1, this.tuning.maxDpr),
},
};
}
private getElement(selector: string): HTMLElement {
const element = document.querySelector<HTMLElement>(selector);
if (!element) throw new Error(`Missing element: ${selector}`);
return element;
}
}
import './styles.css';
import { Game } from './game/Game';
const canvas = document.querySelector<HTMLCanvasElement>('#game-canvas');
if (!canvas) {
throw new Error('Missing #game-canvas element.');
}
const game = new Game(canvas);
game.start();
if (import.meta.hot) {
import.meta.hot.dispose(() => {
game.dispose();
});
}
:root {
color: #f6f1df;
background: #111312;
font-family:
Avenir Next,
Avenir,
"Segoe UI",
sans-serif;
font-synthesis: none;
text-rendering: geometricPrecision;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
body {
min-width: 320px;
}
#app {
position: relative;
background:
radial-gradient(circle at 18% 18%, rgba(245, 186, 73, 0.16), transparent 26rem),
radial-gradient(circle at 82% 72%, rgba(72, 186, 167, 0.14), transparent 24rem),
#111312;
}
#game-canvas {
display: block;
width: 100vw;
height: 100vh;
outline: none;
touch-action: none;
}
#hud {
position: absolute;
top: max(16px, env(safe-area-inset-top));
left: max(16px, env(safe-area-inset-left));
display: flex;
align-items: center;
gap: 10px;
pointer-events: none;
color: #f6f1df;
text-transform: uppercase;
letter-spacing: 0;
}
.hud-metric {
min-width: 92px;
padding: 8px 10px;
border: 1px solid rgba(246, 241, 223, 0.24);
background: rgba(17, 19, 18, 0.72);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.24);
backdrop-filter: blur(10px);
}
.hud-metric span {
display: block;
color: rgba(246, 241, 223, 0.68);
font-size: 0.68rem;
}
.hud-metric strong {
display: block;
margin-top: 2px;
font-size: 1.08rem;
}
#status-line {
min-width: 132px;
padding: 9px 11px;
border-left: 3px solid #f5ba49;
background: rgba(17, 19, 18, 0.6);
font-weight: 700;
font-size: 0.78rem;
}
#touch-controls {
position: absolute;
right: max(18px, env(safe-area-inset-right));
bottom: max(18px, env(safe-area-inset-bottom));
left: max(18px, env(safe-area-inset-left));
display: none;
justify-content: space-between;
align-items: flex-end;
pointer-events: none;
}
#touch-stick {
position: relative;
width: clamp(96px, 24vw, 132px);
aspect-ratio: 1;
border: 1px solid rgba(246, 241, 223, 0.28);
border-radius: 50%;
background: rgba(17, 19, 18, 0.52);
pointer-events: auto;
touch-action: none;
}
#touch-knob {
position: absolute;
top: 50%;
left: 50%;
width: 42%;
aspect-ratio: 1;
border-radius: 50%;
background: #f5ba49;
box-shadow: 0 0 24px rgba(245, 186, 73, 0.44);
transform: translate(-50%, -50%);
}
#dash-button {
width: clamp(74px, 18vw, 96px);
aspect-ratio: 1;
border: 1px solid rgba(246, 241, 223, 0.3);
border-radius: 50%;
color: #111312;
background: #48baa7;
font-weight: 800;
pointer-events: auto;
touch-action: none;
}
@media (pointer: coarse), (max-width: 760px) {
#hud {
right: max(12px, env(safe-area-inset-right));
left: max(12px, env(safe-area-inset-left));
flex-wrap: wrap;
gap: 8px;
}
.hud-metric {
min-width: 78px;
padding: 7px 8px;
}
#status-line {
min-width: min(148px, 48vw);
font-size: 0.72rem;
}
#touch-controls {
display: flex;
}
}
export class AudioSystem {
private context: AudioContext | null = null;
private unlocked = false;
constructor() {
const unlock = () => {
void this.unlock();
window.removeEventListener('pointerdown', unlock);
window.removeEventListener('keydown', unlock);
};
window.addEventListener('pointerdown', unlock, { once: true });
window.addEventListener('keydown', unlock, { once: true });
}
async unlock(): Promise<void> {
if (this.unlocked) return;
const AudioContextClass =
window.AudioContext ||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextClass) return;
this.context = new AudioContextClass();
await this.context.resume();
this.unlocked = true;
}
pickup(index: number): void {
if (!this.context || this.context.state !== 'running') return;
const oscillator = this.context.createOscillator();
const gain = this.context.createGain();
const now = this.context.currentTime;
oscillator.type = 'triangle';
oscillator.frequency.setValueAtTime(320 + index * 22, now);
oscillator.frequency.exponentialRampToValueAtTime(680 + index * 24, now + 0.12);
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(0.08, now + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.18);
oscillator.connect(gain).connect(this.context.destination);
oscillator.start(now);
oscillator.stop(now + 0.2);
}
dispose(): void {
void this.context?.close();
this.context = null;
}
}
import * as THREE from 'three';
export class CameraRig {
private readonly desiredPosition = new THREE.Vector3();
private readonly lookTarget = new THREE.Vector3();
constructor(
private readonly camera: THREE.PerspectiveCamera,
private readonly offset = new THREE.Vector3(0, 9.5, 9.5),
) {}
snapTo(target: THREE.Vector3): void {
this.desiredPosition.copy(target).add(this.offset);
this.camera.position.copy(this.desiredPosition);
this.lookTarget.copy(target).add(new THREE.Vector3(0, 0.4, 0));
this.camera.lookAt(this.lookTarget);
}
update(delta: number, target: THREE.Vector3, lag: number): void {
this.desiredPosition.copy(target).add(this.offset);
const factor = 1 - Math.exp(-delta / Math.max(0.001, lag));
this.camera.position.lerp(this.desiredPosition, factor);
this.lookTarget.copy(target).add(new THREE.Vector3(0, 0.35, -1.2));
this.camera.lookAt(this.lookTarget);
}
}
import * as THREE from 'three';
import type { Pickup } from '../entities/Pickup';
export class CollisionSystem {
private readonly delta = new THREE.Vector3();
collectPickups(playerPosition: THREE.Vector3, pickups: Pickup[], playerRadius: number): Pickup[] {
const collected: Pickup[] = [];
for (const pickup of pickups) {
if (!pickup.active) continue;
this.delta.copy(playerPosition).sub(pickup.group.position);
this.delta.y = 0;
const radius = playerRadius + pickup.radius;
if (this.delta.lengthSq() <= radius * radius) {
pickup.collect();
collected.push(pickup);
}
}
return collected;
}
}
import GUI from 'lil-gui';
import type { PlayerTuning } from '../entities/Player';
export type DebugTuning = PlayerTuning & {
cameraLag: number;
exposure: number;
maxDpr: number;
};
export class DebugTools {
private gui: GUI | null = null;
constructor(tuning: DebugTuning, onChange: () => void) {
const enabled = new URLSearchParams(window.location.search).has('debug');
if (!enabled) return;
this.gui = new GUI({ title: 'Game tuning' });
this.gui.add(tuning, 'speed', 2, 14, 0.1);
this.gui.add(tuning, 'dashMultiplier', 1, 3, 0.05);
this.gui.add(tuning, 'acceleration', 4, 22, 0.1);
this.gui.add(tuning, 'cameraLag', 0.02, 0.8, 0.01);
this.gui.add(tuning, 'maxDpr', 1, 2, 0.25).onChange(onChange);
this.gui.add(tuning, 'exposure', 0.6, 1.8, 0.01).onChange(onChange);
}
dispose(): void {
this.gui?.destroy();
this.gui = null;
}
}
export class Hud {
private readonly scoreValue = this.getElement('#score-value');
private readonly targetValue = this.getElement('#target-value');
private readonly timerValue = this.getElement('#timer-value');
private readonly statusLine = this.getElement('#status-line');
setTarget(target: number): void {
this.targetValue.textContent = String(target);
}
update(score: number, target: number, elapsed: number, complete: boolean): void {
this.scoreValue.textContent = String(score);
this.targetValue.textContent = String(target);
const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0');
const seconds = Math.floor(elapsed % 60).toString().padStart(2, '0');
this.timerValue.textContent = `${minutes}:${seconds}`;
this.statusLine.textContent = complete ? 'Relay grid online' : 'Collect relays';
}
flashPickup(): void {
this.statusLine.animate(
[
{ transform: 'translateY(0)', borderLeftColor: '#f5ba49' },
{ transform: 'translateY(-3px)', borderLeftColor: '#48baa7' },
{ transform: 'translateY(0)', borderLeftColor: '#f5ba49' },
],
{ duration: 220, easing: 'ease-out' },
);
}
private getElement(selector: string): HTMLElement {
const element = document.querySelector<HTMLElement>(selector);
if (!element) throw new Error(`Missing HUD element: ${selector}`);
return element;
}
}
import * as THREE from 'three';
export function disposeObject3D(root: THREE.Object3D): void {
root.traverse((object: THREE.Object3D) => {
const mesh = object as THREE.Mesh;
if (mesh.geometry) {
mesh.geometry.dispose();
}
const materials = Array.isArray(mesh.material) ? mesh.material : mesh.material ? [mesh.material] : [];
for (const material of materials) {
disposeMaterial(material);
}
});
}
function disposeMaterial(material: THREE.Material): void {
const values = Object.values(material as unknown as Record<string, unknown>);
for (const value of values) {
if (isThreeTexture(value)) {
value.dispose();
}
}
material.dispose();
}
function isThreeTexture(value: unknown): value is THREE.Texture {
return Boolean(value && typeof value === 'object' && (value as { isTexture?: boolean }).isTexture);
}
/// <reference types="vite/client" />
interface ThreeGameDiagnostics {
frame: number;
elapsed: number;
score: number;
targetScore: number;
complete: boolean;
player: {
position: { x: number; y: number; z: number };
speed: number;
};
renderer: {
calls: number;
triangles: number;
geometries: number;
textures: number;
};
canvas: {
clientWidth: number;
clientHeight: number;
width: number;
height: number;
dpr: number;
};
}
interface Window {
__THREE_GAME_DIAGNOSTICS__?: ThreeGameDiagnostics;
}
import { expect, test } from '@playwright/test';
import { PNG } from 'pngjs';
type CanvasSample = {
ok: boolean;
reason: string;
variance?: number;
colorBuckets?: number;
};
async function sampleCanvas(page: import('@playwright/test').Page): Promise<CanvasSample> {
const canvas = page.locator('#game-canvas');
const box = await canvas.boundingBox();
if (!box || box.width < 32 || box.height < 32) {
return { ok: false, reason: 'canvas-too-small' };
}
const buffer = await canvas.screenshot();
const png = PNG.sync.read(buffer);
let min = 255;
let max = 0;
let alphaPixels = 0;
const buckets = new Set<string>();
const stride = Math.max(1, Math.floor((png.width * png.height) / 4096));
for (let pixel = 0; pixel < png.width * png.height; pixel += stride) {
const offset = pixel * 4;
const r = png.data[offset];
const g = png.data[offset + 1];
const b = png.data[offset + 2];
const a = png.data[offset + 3];
min = Math.min(min, r, g, b);
max = Math.max(max, r, g, b);
if (a > 0) alphaPixels += 1;
buckets.add(`${r >> 4},${g >> 4},${b >> 4},${a >> 6}`);
}
const variance = max - min;
return {
ok: alphaPixels > 256 && (variance > 8 || buckets.size > 3),
reason: 'sampled',
variance,
colorBuckets: buckets.size,
};
}
test('renders a nonblank interactive game canvas', async ({ page }, testInfo) => {
const consoleErrors: string[] = [];
const pageErrors: string[] = [];
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text());
});
page.on('pageerror', (error) => pageErrors.push(error.message));
await page.goto('/');
await expect(page.locator('#game-canvas')).toBeVisible();
await page.waitForFunction(() => (window.__THREE_GAME_DIAGNOSTICS__?.frame ?? 0) > 10);
const sample = await sampleCanvas(page);
expect(sample, JSON.stringify(sample)).toMatchObject({ ok: true });
const before = await page.evaluate(() => window.__THREE_GAME_DIAGNOSTICS__?.player.position.z ?? 0);
if (testInfo.project.name.includes('mobile')) {
const stick = page.locator('#touch-stick');
await expect(stick).toBeVisible();
const box = await stick.boundingBox();
expect(box).not.toBeNull();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2, box.y + box.height * 0.05, { steps: 6 });
await page.waitForTimeout(450);
await page.mouse.up();
}
} else {
await page.keyboard.down('KeyW');
await page.waitForTimeout(450);
await page.keyboard.up('KeyW');
}
await expect
.poll(async () => page.evaluate(() => window.__THREE_GAME_DIAGNOSTICS__?.player.position.z ?? 0))
.toBeLessThan(before - 0.3);
const screenshot = await page.screenshot({ fullPage: true });
await testInfo.attach(`${testInfo.project.name}-game`, {
body: screenshot,
contentType: 'image/png',
});
expect(consoleErrors).toEqual([]);
expect(pageErrors).toEqual([]);
});
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client", "node"]
},
"include": ["src", "tests", "playwright.config.ts"]
}
import { defineConfig } from 'vite';
export default defineConfig({
server: {
host: '127.0.0.1',
port: 5188,
strictPort: true,
},
preview: {
host: '127.0.0.1',
port: 4188,
strictPort: true,
},
build: {
sourcemap: true,
chunkSizeWarningLimit: 900,
},
});
Endless Runner Premium Quality Checklist
- Player avatar or vehicle has a strong readable silhouette from the chase camera.
- Player avatar or vehicle includes visible subassemblies such as body shell, cockpit/core, fins/wings, engines, trim, emissive state, and trail.
- Player collision footprint is visually understandable at speed.
- Obstacle families have distinct silhouettes, colors/materials, and telegraphing.
- At least three obstacle families are present: low barrier, gate/arch, moving/animated or special hazard.
- Rewards/pickups are readable at distance and have collection feedback.
- At least two reward/interactable variants exist beyond one recolored pickup.
- Lane/path markings communicate safe choices without turning into visual noise.
- Foreground, midground, and background layers create speed and depth.
- World segments include reusable procedural prop kits, not only stretched boxes.
- Skyline/world modules include rooflines, setbacks, signage/window bands, supports, cables, antennas, or parallax layers.
- Speed effects reinforce motion while preserving the next lane decision.
- Camera FOV, follow smoothing, shake, roll, and horizon framing support speed without nausea.
- HUD prioritizes run state: distance/progress, score/streak, health/shield, boost/overdrive, fail/retry.
- HUD is not a grid of generic debug/stat cards unless that is an intentional diegetic style.
- Fail, crash, near miss, boost, milestone, and restart states have polished feedback.
- Mobile layout keeps controls away from hazards, player focal area, and safe-area edges.
- Worst-case segment performance is checked with renderer diagnostics.
- The game is playtested through ramp-up, ordinary collection, hazard avoidance, failure, and restart.
New Game Definition Of Done
- The project installs with
npm install. npm run devstarts a local Vite server.npm run buildcompletes.- The first screen is the game, not a landing page.
- The player can interact within 5 seconds.
- There is a clear objective, score, timer, health, level target, or fail condition.
- Keyboard/mouse input works on desktop.
- Touch or pointer input works if mobile is in scope.
- The camera frames the playable area at desktop and mobile sizes.
- HUD text is readable and does not cover critical gameplay.
- Browser console has no blocking errors.
- A screenshot proves the canvas rendered.
- A canvas-pixel check proves the canvas is not blank.
- Final report names controls, verification evidence, and remaining risks.
Gameplay Workflows
Use this reference for first playable slices, architecture, mechanics, entities, controls, camera, physics, audio hooks, and game-feel iteration.
First Playable Slice
The first slice must be playable, not just rendered.
1. Inspect folder, scripts, dependencies, current renderer, app entrypoint, CSS, assets, and tests. 2. Define the loop in one sentence: verb, objective, pressure, reward, fail/retry. 3. Implement only the mechanics needed for that loop:
- renderer and scene
- camera and resize
- update/render loop
- input intents
- player entity
- one obstacle/enemy or challenge
- one reward/progress path
- collision/trigger checks
- score/status state
- fail/retry state
- minimal HUD state
- one audio/VFX feedback hook
4. Add diagnostics when possible:
window.__THREE_GAME_DIAGNOSTICS__- renderer info
- game state snapshot
- input state
- active entity counts
5. Verify build, browser, console/page errors, screenshot, nonblank canvas, and one real input path.
Reject a slice that cannot be controlled or restarted.
Architecture Boundaries
Prefer simple modules once the prototype grows beyond one file:
main: DOM bootstrap, app lifecycle, CSS imports.core: renderer, loop, resize, input, diagnostics.game: orchestration, state transitions, update order, scoring/objectives.entities: player, enemies, pickups, projectiles, obstacles.systems: camera, collision/physics, spawning, animation, audio, UI bridge, debug.assets: material libraries, procedural textures, model factories, loaders, disposal.tests: browser, visual, interaction, mobile, performance smoke checks.
Keep update order explicit:
input -> fixed physics if any -> gameplay systems -> animation/VFX -> camera -> UI bridge -> renderDo not invent abstractions before the mechanics need them. Do extract duplicated entity, input, collision, and asset logic once multiple features share it.
Imported Generated 3D Assets And Animation
When gameplay uses threejs-3d-generator GLB/FBX assets:
- Load GLB assets with
GLTFLoaderfromthree/addons/loaders/GLTFLoader.js. - Keep imported model loading in the asset layer, not inside entity update loops.
- Wrap imported scenes in game entities with explicit scale, bounds, collision proxy, and state hooks.
- Use
AnimationMixerfor rigged/animated GLBs and update mixers withdeltaSeconds. - Map gameplay states to clips: idle, walk/run, jump, attack/slash/shoot, hurt, fall, turn.
- Decide whether root motion is used. For arcade games, prefer in-place animation and move the entity in code.
- Keep simple collision proxies independent from the detailed imported mesh.
- Add fallback placeholders or loading states if an asset fails to load.
- Report file size, clip names, approximate triangles, and texture count after import.
Input And Intent
- Convert keyboard, pointer, touch, and gamepad where relevant into game intents.
- Keep input collection separate from simulation.
- Support both desktop and mobile when the user asks for a browser game unless explicitly desktop-only.
- Handle pointer release/cancel/blur so controls do not stick.
- Keep CSS
touch-actionintentional and scoped. - Preserve focus and restart controls after fail/pause.
Camera And Controls
Tune controls and camera together.
- Movement: acceleration, deceleration, friction, turn rate, max speed, jump/gravity/boost.
- Camera: follow lag, look-ahead, FOV, height, distance, shake, collision/framing.
- Readability: next decision visible, player centered enough, threats not hidden by UI.
- Feedback: hit pause, camera impulse, FOV kick, meter pulse, audio pitch, VFX socket.
- Accessibility: avoid excessive shake, strobe, and uncontrollable motion.
Use lil-gui for live constants when repeated tuning is likely, but gate debug UI from release.
Collision And Physics
Choose the lightest reliable approach:
- Simple custom collision: arcade triggers, lanes, runners, pickups, bullets, simple arenas.
- Rapier: default robust choice for serious browser-game physics: balls, pinball, mini-golf, pool/snooker, moving platforms, sensors, rigid bodies, slopes/ramps, high-speed collisions, many contacts, and WASM-backed simulation.
cannon-es: lightweight JS fallback for small/simple rigid-body scenes when avoiding WASM matters.
When physics is in scope, also load references/physics-engine-selection.md before choosing an engine.
Rules:
- Keep collision proxies simple and visible in debug mode.
- Do not use detailed visual meshes for collision.
- Clamp delta or use fixed-step simulation for physics.
- Reconcile physics transforms and visual transforms in one place.
- Test high-speed movement against tunneling and camera loss.
- Report engine choice, package installed, timestep, body count, collider count, CCD use, sensors, and risky colliders.
- For Rapier, initialize WASM once, step with a fixed accumulator, use primitive/compound colliders, enable CCD only for fast bodies, and dispose bodies/colliders on restart.
Gameplay Implementation Loop
For each mechanic:
1. Add state/data. 2. Add simulation/update. 3. Add visual representation. 4. Add feedback: UI, audio, VFX, camera, animation. 5. Add diagnostics. 6. Verify with real input and one failing edge case.
Examples:
- Pickup: spawn data, collision trigger, score/meter state, collect VFX/audio, HUD pulse, respawn/cleanup.
- Hazard: telegraph, movement/update, collision proxy, damage/fail state, hit feedback, restart.
- Combo: timer/state, reward multiplier, UI badge, audio ramp, reset rules.
- Weapon/action: cooldown, projectile/hit, impact feedback, ammo/charge UI, target readability.
Game Feel Pass
Run several short loops and tune one axis at a time:
- Movement speed and acceleration.
- Camera distance, follow, and look-ahead.
- Reaction windows and obstacle spacing.
- Jump/boost/attack cooldowns.
- Pickup magnetism and reward timing.
- Hit feedback and restart speed.
- Difficulty ramp and pacing.
Record meaningful constants changed. If the game feels worse after a pass, revert or reduce the last tuning change instead of layering compensating changes.
Audio Hooks
Use lightweight Web Audio or project audio utilities:
- UI click/pause/retry.
- Pickup/score.
- Damage/fail.
- Boost/speed.
- Combo/milestone.
- Ambient loop or procedural drone when appropriate.
Audio should reflect state, not play random decoration. Respect mute and reduced-motion/accessibility settings when present.
Diagnostics
Expose:
- FPS/frame time if available.
- Renderer info.
- Current state.
- Player position/velocity.
- Entity counts.
- Active collisions/hits.
- Input intents.
- Tunable constants when using debug GUI.
Diagnostics should be easy to disable or gate for release.
Verification
Minimum evidence after meaningful gameplay work:
npm run buildor equivalent.- Local browser run.
- Console/page error check.
- Nonblank canvas pixel check.
- Desktop screenshot.
- Mobile screenshot when in scope.
- Main input path tested.
- Objective progression tested.
- Fail/retry tested when relevant.
Common Failures
- Static scene instead of game.
- Multiple loops fighting.
- Camera clips, points away, or hides the next decision.
- Mechanic cannot be triggered from real input.
- HUD/audio/VFX do not reflect state changes.
- Faster movement breaks collision or camera framing.
- Restart leaves stale entities, timers, listeners, or effects.
- Mobile input works visually but does not emit game intents.
- Imported model loads but has wrong scale, pivot, orientation, animation root motion, or no collision proxy.
Physics Engine Selection
Use this reference before adding or changing physics, collision-heavy gameplay, vehicle movement, ball sports, pinball, mini-golf, rigid-body puzzles, destructible props, character controllers, sensors, or high-speed collision.
Current Recommendation
Default to this ladder:
1. Custom collision: arcade triggers, pickups, lanes, bullets, simple overlap tests, deterministic rails, or games where authored feel matters more than simulation. 2. Rapier: default robust physics choice for serious Three.js browser games that need rigid bodies, colliders, sensors, many contacts, fast objects, balls, vehicles, ramps, slopes, moving platforms, or reliable browser/mobile behavior. 3. cannon-es: lightweight JavaScript fallback for small rigid-body scenes when avoiding WASM matters and collision complexity is low. 4. Jolt: consider for advanced rigid-body projects that need its engine-specific strengths, but expect more integration complexity. 5. Ammo.js/Bullet: use only when a project already depends on it or needs an existing Ammo-specific feature/wrapper. Avoid as the default new-project choice. 6. Matter.js: 2D only; use only for 2D gameplay rendered with Three.js.
For most new premium Three.js games with real physics, choose Rapier.
Why Rapier Is The Default
Three.js is a renderer, not a physics engine. Physics uses a separate world updated at a fixed timestep, then visual meshes are synchronized from physics body transforms.
Rapier is a Rust/WASM physics engine with official JavaScript bindings. It supports 2D and 3D worlds, rigid bodies, colliders, sensors, collision events, forces/impulses, damping, locking axes/rotations, sleeping, and continuous collision detection for fast bodies. It is a good default because it is fast, modern, typed, and strong enough for browser games with meaningful simulation.
Choose By Game Type
Use custom collision:
- Endless runners, lane dodgers, simple shooters, pickups, checkpoint gates, scripted hazards.
- Transform-driven racers where car feel is custom and barriers are simple.
- Starship dogfights with sphere/box/projectile overlap.
Use Rapier:
- Mini golf, pool/snooker, pinball, marble racers, physics puzzles, rolling balls.
- Platformers, moving platforms, ramps, slopes, character controllers.
- Dynamic debris, rigid-body stacks, crates, destructible props.
- High-speed bullets/balls that need CCD.
- Trigger/sensor-heavy games needing collision events.
- Vehicle-like games where wheel simulation is not required but collisions and ramps matter.
Use cannon-es:
- Small JS-only demos or prototypes with modest body counts.
- Simple arcade rigid bodies where package simplicity matters more than robustness.
Use Jolt:
- Advanced rigid-body experiments where Jolt's behavior is explicitly desired and the team can handle WASM integration details.
Avoid detailed visual mesh collision:
- Use primitive colliders, compound colliders, convex hulls, simplified triangle meshes only for fixed level geometry, and explicit sensor volumes.
- Imported generated GLB meshes should get separate collision proxies.
Rapier Setup Pattern
Install:
npm install @dimforge/rapier3d-compatInitialize once before gameplay starts:
import RAPIER from '@dimforge/rapier3d-compat';
await RAPIER.init();
const gravity = { x: 0, y: -9.81, z: 0 };
const world = new RAPIER.World(gravity);Step at a fixed timestep:
const fixedDt = 1 / 60;
let accumulator = 0;
function update(deltaSeconds: number) {
accumulator += Math.min(deltaSeconds, 0.1);
while (accumulator >= fixedDt) {
world.timestep = fixedDt;
world.step();
accumulator -= fixedDt;
}
}Create bodies and colliders:
const bodyDesc = RAPIER.RigidBodyDesc.dynamic()
.setTranslation(0, 2, 0)
.setLinearDamping(0.25)
.setAngularDamping(0.5);
const body = world.createRigidBody(bodyDesc);
const collider = RAPIER.ColliderDesc.ball(0.5)
.setRestitution(0.6)
.setFriction(0.4);
world.createCollider(collider, body);Sync physics to Three.js in one system:
const t = body.translation();
const r = body.rotation();
mesh.position.set(t.x, t.y, t.z);
mesh.quaternion.set(r.x, r.y, r.z, r.w);For high-speed objects:
const fastBody = world.createRigidBody(
RAPIER.RigidBodyDesc.dynamic().setCcdEnabled(true)
);For sensors/triggers:
const sensor = RAPIER.ColliderDesc.ball(1)
.setSensor(true)
.setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS);Architecture Rules
- Put physics ownership in
systems/PhysicsWorld,systems/CollisionSystem, or equivalent. - Do not create physics bodies inside render code.
- Keep physics body handles in entities and dispose them on restart.
- Update order: input intents -> fixed physics -> game state/collisions -> VFX/camera/UI -> render.
- Keep a debug overlay or helper mode for colliders, body counts, contact pairs, and velocity.
- Use kinematic bodies for moving platforms and scripted obstacles.
- Use sensors for pickups, goals, holes, portals, checkpoints, triggers, and damage zones.
- Use CCD only for fast bodies because it costs extra.
- Use sleeping and body removal/disposal to avoid stale simulation state.
Tuning Rules
- Tune in units that map cleanly to scene scale.
- Clamp frame delta before the accumulator.
- Tune friction, restitution, damping, mass/density, gravity scale, and collision groups explicitly.
- For balls, mini-golf, pool, and pinball: tune rolling friction/damping, restitution, cushion/boundary bounce, hole capture, and max velocity.
- For arcade vehicles: do not rely on raw rigid-body simulation alone; combine kinematic/control logic with collision response.
- For character controllers: prefer capsule colliders, locked rotations, and kinematic movement unless ragdoll behavior is required.
Verification Requirements
For physics work, verify:
- Build/typecheck.
- Browser run with console/page error check.
- Real input changes body state.
- Collision/trigger path works.
- Restart removes or resets physics bodies.
- High-speed movement does not tunnel.
- Mobile/low-FPS frame spikes do not break simulation.
- Physics diagnostics: engine used, body count, collider count, timestep, CCD bodies, sensors, collision groups, and risky colliders.
Common Failures
- Variable-delta physics causes nondeterministic feel.
- Visual mesh and physics body drift because transforms are synced in multiple places.
- Bodies persist after restart.
- Detailed imported meshes make collision slow or wrong.
- Fast balls/projectiles tunnel because CCD is missing.
- Sensors are missing active events.
- Kinematic platforms move visually but not physically.
- Debug code exposes physics internals or API keys in release.
Source Basis
- Three.js manual: physics engines run a parallel physics world, often on fixed timesteps, then synchronize mesh transforms.
- Three.js manual: cannon-es is lightweight and easy to integrate, while WASM engines such as Rapier/Jolt/Ammo offer more performance/features with more setup.
- Rapier JavaScript docs: use
@dimforge/rapier3d-compat, initialize withRAPIER.init(), create rigid bodies and colliders, use forces/impulses/damping/CCD, and enable collision events on colliders.
Three.js Gameplay Systems Prompt Templates
Reusable prompt templates packaged with this skill. Use only templates relevant to the current request, and adapt placeholders to the game/project context.
---
Gameplay System Prompt
Use threejs-gameplay-systems to add or modify this gameplay system:
System:
- Player-facing behavior:
- Entities affected:
- State changes:
- Collision/physics needs:
- Audio/visual/HUD feedback:
- Edge cases:
Constraints:
- Preserve existing controls and camera unless the task asks to change them.
- Keep update order deterministic.
- Use simple custom collision unless the mechanics justify a physics library.
- Add debug controls only when they speed tuning.
- Verify through build, local browser run, console check, screenshot, canvas-pixel check, and interaction test.
#!/usr/bin/env python3
"""Create a Three.js Vite game from the packaged skill scaffold."""
from __future__ import annotations
import argparse
import json
import re
import shutil
import sys
from pathlib import Path
EXCLUDE_DIRS = {
"node_modules",
"dist",
"artifacts",
"test-results",
"playwright-report",
"coverage",
"__pycache__",
}
EXCLUDE_FILES = {".DS_Store"}
def skill_dir() -> Path:
return Path(__file__).resolve().parents[1]
def scaffold_dir() -> Path:
return skill_dir() / "assets" / "threejs-vite-game"
def normalized_project_name(target: Path) -> str:
name = re.sub(r"[^a-z0-9._-]+", "-", target.resolve().name.lower()).strip("-")
return name or "threejs-vite-game"
def ignore(_directory: str, names: list[str]) -> set[str]:
ignored: set[str] = set()
for name in names:
if name in EXCLUDE_DIRS or name in EXCLUDE_FILES:
ignored.add(name)
return ignored
def rewrite_json_name(path: Path, name: str) -> None:
if not path.exists():
return
data = json.loads(path.read_text(encoding="utf-8"))
data["name"] = name
if isinstance(data.get("packages"), dict) and "" in data["packages"]:
data["packages"][""]["name"] = name
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
def create_game(target: Path, force: bool) -> None:
source = scaffold_dir()
if not source.is_dir():
raise SystemExit(f"Scaffold not found: {source}")
if target.exists() and any(target.iterdir()) and not force:
raise SystemExit(f"Target is not empty: {target}\nUse --force to copy into it anyway.")
target.mkdir(parents=True, exist_ok=True)
shutil.copytree(source, target, dirs_exist_ok=True, ignore=ignore)
project_name = normalized_project_name(target)
rewrite_json_name(target / "package.json", project_name)
rewrite_json_name(target / "package-lock.json", project_name)
print(f"Created Three.js game scaffold at {target.resolve()}")
print(f"Next: cd {target} && npm install && npm run dev")
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description="Create a Vite + TypeScript + Three.js browser game scaffold.")
parser.add_argument("target", help="Target directory to create or populate.")
parser.add_argument("--force", action="store_true", help="Copy into a non-empty target directory.")
args = parser.parse_args(argv)
create_game(Path(args.target), args.force)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))