agents/openai.yaml
interface:
display_name: "Three.js Gameplay Systems"
short_description: "Design and build playable Three.js systems"
default_prompt: "Load threejs-gameplay-systems/SKILL.md (try ~/.codex/skills/threejs-gameplay-systems/SKILL.md, then ~/.claude/skills, ~/.agents/skills, or repo skills/) and follow it exactly, including its reference-loading rules and reference ledger. Non-negotiables: design brief, core loop contract, and level/encounter plan before broad implementation; verify through build, browser, and one real input path; report the reference ledger and checklist outcomes."
assets/threejs-vite-game/.gitignore
node_modules/
dist/
test-results/
artifacts/
*.log
.DS_Store
assets/threejs-vite-game/index.html
<!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>
assets/threejs-vite-game/package-lock.json
{
"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"
}
}
}
}
assets/threejs-vite-game/package.json
{
"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"
}
}
assets/threejs-vite-game/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
// One worker: parallel headless WebGL contexts contend for the GPU, and the
// frame-time collapse makes game time drift from wall time, flaking timed
// gameplay phases and screenshot baselines.
workers: 1,
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'],
// devices['Desktop Chrome'] sets no channel, so Playwright launches the
// bundled headless shell, which has no GPU backend and falls back to
// SwiftShader (CPU) — roughly 4x slower raster and meaningless FPS.
// The full Chromium build renders headless on the real GPU.
channel: 'chromium',
viewport: { width: 1280, height: 720 },
},
},
{
name: 'mobile-safari',
use: {
...devices['iPhone 13'],
},
},
],
});
assets/threejs-vite-game/scripts/inspect-threejs-canvas.mjs
#!/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';
// Starting-point render budgets (see threejs-aaa-graphics-builder
// references/technical-art.md). Over-budget rows are reported, not fatal.
const RENDER_BUDGETS = {
desktop: { calls: 300, triangles: 750_000, geometries: 300, textures: 60 },
mobile: { calls: 150, triangles: 300_000, geometries: 200, textures: 40 },
};
function parseArgs(argv) {
const args = {
url: 'http://127.0.0.1:5188',
out: 'artifacts/canvas-inspection',
mobile: false,
wait: 750,
state: null,
seed: undefined,
};
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 === '--state') args.state = argv[++i];
else if (value === '--seed') args.seed = Number(argv[++i]);
else if (value === '-h' || value === '--help') {
console.log(
'Usage: inspect-threejs-canvas.mjs [--url URL] [--out DIR] [--mobile] [--wait MS] [--state NAME] [--seed N]\n' +
' --state/--seed drive window.__THREE_GAME_TEST_HOOKS__ (setState/seed) before capture\n' +
' so specific game states can be measured deterministically.',
);
process.exit(0);
} else {
throw new Error(`Unknown argument: ${value}`);
}
}
return args;
}
const round = (value, digits) => Number(value.toFixed(digits));
// Objective pixel statistics used as "Measured Evidence" in the visual
// scorecard. Computed on a coarse luminance grid so cost stays trivial.
function computePixelMetrics(png) {
const stepX = Math.max(1, Math.floor(png.width / 160));
const stepY = Math.max(1, Math.floor(png.height / 90));
const cols = Math.floor(png.width / stepX);
const rows = Math.floor(png.height / stepY);
const luminance = new Float64Array(cols * rows);
const bucketCounts = new Map();
let samples = 0;
for (let gy = 0; gy < rows; gy += 1) {
for (let gx = 0; gx < cols; gx += 1) {
const offset = ((gy * stepY) * png.width + gx * stepX) * 4;
const r = png.data[offset];
const g = png.data[offset + 1];
const b = png.data[offset + 2];
luminance[gy * cols + gx] = 0.2126 * r + 0.7152 * g + 0.0722 * b;
const key = `${r >> 4},${g >> 4},${b >> 4}`;
bucketCounts.set(key, (bucketCounts.get(key) ?? 0) + 1);
samples += 1;
}
}
const sorted = Array.from(luminance).sort((a, b) => a - b);
const mean = sorted.reduce((sum, v) => sum + v, 0) / sorted.length;
const p5 = sorted[Math.floor(sorted.length * 0.05)];
const p95 = sorted[Math.floor(sorted.length * 0.95)];
let entropy = 0;
let dominant = 0;
for (const count of bucketCounts.values()) {
const p = count / samples;
entropy -= p * Math.log2(p);
dominant = Math.max(dominant, count);
}
let edges = 0;
let checked = 0;
for (let gy = 0; gy < rows - 1; gy += 1) {
for (let gx = 0; gx < cols - 1; gx += 1) {
const i = gy * cols + gx;
const dx = Math.abs(luminance[i] - luminance[i + 1]);
const dy = Math.abs(luminance[i] - luminance[i + cols]);
if (Math.max(dx, dy) > 12) edges += 1;
checked += 1;
}
}
return {
colorBuckets: bucketCounts.size,
colorEntropyBits: round(entropy, 2),
edgeDensity: round(edges / checked, 3),
luminance: {
mean: round(mean, 1),
p5: round(p5, 1),
p95: round(p95, 1),
contrast: round(p95 - p5, 1),
},
dominantColorShare: round(dominant / samples, 3),
nonBackgroundShare: round(1 - dominant / samples, 3),
};
}
// Playwright's default headless is chromium_headless_shell, which ships no GPU
// backend and silently falls back to SwiftShader (CPU). Every frame-time and FPS
// number measured that way is software-rendered fiction. channel:'chromium' runs
// the full Chromium build in new headless mode against the real GPU.
async function launchBrowser() {
try {
return await chromium.launch({ channel: 'chromium' });
} catch {
console.error(
'warning: channel:"chromium" is unavailable, falling back to the bundled headless shell.\n' +
' Rendering will be software (SwiftShader) and any FPS/frame-time evidence is invalid.\n' +
' Fix with: npx playwright install chromium',
);
return chromium.launch();
}
}
// Records which GPU actually rasterized the run, so a software fallback can never
// masquerade as performance evidence again. Reuses the game's own context when it
// is WebGL rather than allocating a second one.
async function readGpuInfo(page) {
const info = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
if (!canvas) return null;
let gl = null;
try {
gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl');
} catch {
gl = null;
}
if (!gl) return null;
const debug = gl.getExtension('WEBGL_debug_renderer_info');
return {
renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER),
vendor: debug ? gl.getParameter(debug.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR),
};
});
if (!info?.renderer) {
return { renderer: null, vendor: null, softwareRendered: null };
}
return {
...info,
softwareRendered: /swiftshader|llvmpipe|software|basic render/i.test(info.renderer),
};
}
function checkRenderBudget(renderer, mode) {
if (!renderer) return null;
const budget = RENDER_BUDGETS[mode];
const rows = Object.entries(budget).map(([metric, limit]) => {
const actual = renderer[metric];
return {
metric,
actual: typeof actual === 'number' ? actual : null,
limit,
ok: typeof actual === 'number' ? actual <= limit : null,
};
});
return {
tier: mode,
note: 'starting-point budget; adjust per game and document overrides',
rows,
withinBudget: rows.every((row) => row.ok !== false),
};
}
async function sampleCanvas(page, mode) {
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,
metrics: computePixelMetrics(png),
renderBudget: checkRenderBudget(diagnostics.game?.renderer ?? null, mode),
diagnostics: diagnostics.game,
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
await mkdir(args.out, { recursive: true });
const browser = await launchBrowser();
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 });
if (args.state || args.seed !== undefined) {
const applied = await page.evaluate(({ seed, state }) => {
const hooks = window.__THREE_GAME_TEST_HOOKS__;
if (!hooks) return false;
if (typeof seed === 'number') hooks.seed?.(seed);
if (state) hooks.setState?.(state);
return true;
}, { seed: args.seed, state: args.state });
if (!applied) {
console.error(
'warning: --state/--seed requested but __THREE_GAME_TEST_HOOKS__ is not defined; capturing the current state instead',
);
}
}
await page.waitForTimeout(args.wait);
const mode = args.mobile ? 'mobile' : 'desktop';
const baseName = args.state ? `${mode}-${args.state}` : mode;
const gpu = await readGpuInfo(page);
const result = await sampleCanvas(page, mode);
const screenshotPath = path.join(args.out, `${baseName}.png`);
await page.screenshot({ path: screenshotPath, fullPage: true });
if (gpu.softwareRendered) {
console.error(
`warning: this run rasterized on ${gpu.renderer} (software). Pixel and budget ` +
'checks remain valid; any FPS or frame-time reading from it does not.',
);
}
const report = {
url: args.url,
mode,
state: args.state,
seed: args.seed ?? null,
screenshotPath,
gpu,
result,
consoleErrors,
pageErrors,
};
await writeFile(path.join(args.out, `${baseName}.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);
});
assets/threejs-vite-game/src/core/InputController.ts
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))`;
}
}
assets/threejs-vite-game/src/core/Loop.ts
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);
};
}
assets/threejs-vite-game/src/core/Renderer.ts
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;
}
assets/threejs-vite-game/src/entities/Pickup.ts
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;
}
reset(): void {
this.active = true;
this.group.visible = true;
}
dispose(): void {
this.coreGeometry.dispose();
this.ringGeometry.dispose();
this.coreMaterial.dispose();
this.ringMaterial.dispose();
}
}
assets/threejs-vite-game/src/entities/Player.ts
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();
}
}
assets/threejs-vite-game/src/game/Game.ts
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';
import { createSeededRandom } from '../utils/random';
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;
// Route ALL gameplay randomness through this.rng (never Math.random) so the
// seed() test hook keeps screenshot baselines and bot playtests deterministic.
private rng = createSeededRandom(1);
private pausedForScreenshot = false;
private reducedMotion = 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.installTestHooks();
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;
window.__THREE_GAME_TEST_HOOKS__ = undefined;
}
private update(delta: number, elapsed: number): void {
this.frame += 1;
if (this.pausedForScreenshot) {
this.publishDiagnostics();
return;
}
if (!this.complete) this.elapsed += delta;
resizeRenderer(this.renderer, this.camera, this.tuning.maxDpr);
// Reduced motion freezes ambient animation time so screenshots are stable.
const animDelta = this.reducedMotion ? 0 : delta;
const animElapsed = this.reducedMotion ? 0 : elapsed;
this.player.update(delta, animElapsed, this.input, this.tuning, ARENA);
for (const pickup of this.pickups) {
pickup.update(animDelta, animElapsed);
}
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 installTestHooks(): void {
// Deterministic hooks consumed by tests/visual-regression.template.ts and
// bot playtests. Keep these real when evolving the game: silent no-op hooks
// produce flaky screenshot baselines.
window.__THREE_GAME_TEST_HOOKS__ = {
seed: (value: number) => {
this.rng = createSeededRandom(value);
},
setState: (name: string) => {
if (name === 'active-play') this.resetRun();
else if (name === 'complete') this.completeRun();
else console.warn(`Unknown test state: ${name}`);
},
setPausedForScreenshot: (paused: boolean) => {
this.pausedForScreenshot = paused;
},
setReducedMotion: (enabled: boolean) => {
this.reducedMotion = enabled;
},
hideDebugUi: (hidden: boolean) => {
this.debugTools.setHidden(hidden);
},
};
}
private resetRun(): void {
this.score = 0;
this.elapsed = 0;
this.complete = false;
this.player.group.position.set(0, this.player.group.position.y, 0);
this.player.velocity.set(0, 0, 0);
for (const pickup of this.pickups) {
pickup.reset();
pickup.group.rotation.y = this.rng() * Math.PI * 2;
}
this.cameraRig.snapTo(this.player.group.position);
this.hud.setTarget(this.pickups.length);
this.hud.update(this.score, this.pickups.length, this.elapsed, this.complete);
}
private completeRun(): void {
for (const pickup of this.pickups) {
if (pickup.active) pickup.collect();
}
this.score = this.pickups.length;
this.complete = true;
this.hud.update(this.score, this.pickups.length, this.elapsed, this.complete);
}
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;
}
}
assets/threejs-vite-game/src/main.ts
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();
});
}
assets/threejs-vite-game/src/styles.css
: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;
}
}
assets/threejs-vite-game/src/systems/AudioSystem.ts
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;
}
}
assets/threejs-vite-game/src/systems/CameraRig.ts
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);
}
}
assets/threejs-vite-game/src/systems/CollisionSystem.ts
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;
}
}
assets/threejs-vite-game/src/systems/DebugTools.ts
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);
}
setHidden(hidden: boolean): void {
if (!this.gui) return;
if (hidden) this.gui.hide();
else this.gui.show();
}
dispose(): void {
this.gui?.destroy();
this.gui = null;
}
}
assets/threejs-vite-game/src/systems/Hud.ts
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;
}
}
assets/threejs-vite-game/src/utils/dispose.ts
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);
}
assets/threejs-vite-game/src/utils/random.ts
/**
* Deterministic seeded RNG (mulberry32). Route ALL gameplay randomness through
* a seeded generator instead of Math.random so the __THREE_GAME_TEST_HOOKS__
* seed() hook keeps visual baselines and bot playtests reproducible.
*/
export function createSeededRandom(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
assets/threejs-vite-game/src/vite-env.d.ts
/// <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 ThreeGameTestHooks {
/** Re-seed the game RNG; all gameplay randomness must flow through it. */
seed(value: number): void;
/** Jump to a named state for baselines (scaffold: 'active-play' | 'complete'). */
setState(name: string): void;
/** Freeze the simulation while continuing to render the current frame. */
setPausedForScreenshot(paused: boolean): void;
/** Freeze ambient/idle animation time so screenshots are stable. */
setReducedMotion(enabled: boolean): void;
/** Hide debug UI (lil-gui) before capturing. */
hideDebugUi(hidden: boolean): void;
}
interface Window {
__THREE_GAME_DIAGNOSTICS__?: ThreeGameDiagnostics;
__THREE_GAME_TEST_HOOKS__?: ThreeGameTestHooks;
}
assets/threejs-vite-game/tests/bot-playtest.template.ts
import { expect, test } from '@playwright/test';
// Copy this file to tests/bot-playtest.spec.ts to enable automated playtests.
// The bot drives the game through scripted real input and measures whether the
// game actually progresses: score/objective movement, player motion, softlock
// windows, and error-free play. Adapt `INPUT_SCRIPT` to your game's controls
// and level layout — keep the progression assertion; a bot that cannot make
// the objective move is evidence the game is unclear or broken.
//
// Requires window.__THREE_GAME_DIAGNOSTICS__ (frame/score/complete/player) and
// window.__THREE_GAME_TEST_HOOKS__ (seed/setState) — both ship in the scaffold.
type BotSnapshot = {
frame: number;
score: number;
complete: boolean;
x: number;
z: number;
};
// Keyboard sweep tuned to the scaffold arena (22x14, pickups in two rows).
// Each step holds keys for `ms` then samples diagnostics.
const INPUT_SCRIPT: Array<{ keys: string[]; ms: number }> = [
{ keys: ['KeyW'], ms: 1000 },
{ keys: ['KeyA'], ms: 1900 },
{ keys: ['KeyD'], ms: 3400 },
{ keys: ['KeyW'], ms: 350 },
{ keys: ['KeyA'], ms: 3400 },
{ keys: ['KeyS'], ms: 1700 },
{ keys: ['KeyD'], ms: 3400 },
{ keys: ['KeyS'], ms: 350 },
{ keys: ['KeyA'], ms: 3400 },
];
test('bot playtest: scripted input drives progress without errors', async ({ page }, testInfo) => {
test.skip(
testInfo.project.name !== 'desktop-chrome',
'The bot uses keyboard input; mobile touch input is exercised by visual.spec.ts.',
);
test.setTimeout(90_000);
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text());
});
await page.goto('/');
await page.waitForFunction(() => (window.__THREE_GAME_DIAGNOSTICS__?.frame ?? 0) > 10);
await page.evaluate(() => {
window.__THREE_GAME_TEST_HOOKS__?.seed(12345);
window.__THREE_GAME_TEST_HOOKS__?.setState('active-play');
});
const sample = (): Promise<BotSnapshot | null> =>
page.evaluate(() => {
const d = window.__THREE_GAME_DIAGNOSTICS__;
if (!d) return null;
return {
frame: d.frame,
score: d.score,
complete: d.complete,
x: d.player.position.x,
z: d.player.position.z,
};
});
const before = await sample();
expect(before, 'diagnostics must be published before the bot can play').not.toBeNull();
const snapshots: BotSnapshot[] = [before as BotSnapshot];
let softlockWindows = 0;
let distance = 0;
let stepOfFirstScore = -1;
for (const [index, step] of INPUT_SCRIPT.entries()) {
for (const key of step.keys) await page.keyboard.down(key);
await page.waitForTimeout(step.ms);
for (const key of step.keys) await page.keyboard.up(key);
const snap = await sample();
const prev = snapshots[snapshots.length - 1];
if (snap) {
const moved = Math.hypot(snap.x - prev.x, snap.z - prev.z);
distance += moved;
const progressed = snap.score > prev.score || (snap.complete && !prev.complete);
if (progressed && stepOfFirstScore === -1) stepOfFirstScore = index;
// Softlock signature: frames advance while held input neither moves the
// player nor progresses the objective.
if (snap.frame > prev.frame && moved < 0.2 && !progressed) softlockWindows += 1;
snapshots.push(snap);
}
}
const after = snapshots[snapshots.length - 1];
const report = {
steps: INPUT_SCRIPT.length,
framesAdvanced: after.frame - (before as BotSnapshot).frame,
scoreBefore: (before as BotSnapshot).score,
scoreAfter: after.score,
complete: after.complete,
distanceTravelled: Number(distance.toFixed(2)),
stepOfFirstScore,
softlockWindows,
consoleErrors,
pageErrors,
};
await testInfo.attach('bot-playtest-report', {
body: JSON.stringify(report, null, 2),
contentType: 'application/json',
});
console.log(`bot playtest: ${JSON.stringify(report)}`);
expect(pageErrors, 'page errors during bot play').toEqual([]);
expect(consoleErrors, 'console errors during bot play').toEqual([]);
expect(report.framesAdvanced, 'game loop must keep running').toBeGreaterThan(100);
expect(report.distanceTravelled, 'player must respond to scripted input').toBeGreaterThan(5);
expect(report.softlockWindows, 'held input repeatedly produced no motion or progress').toBeLessThanOrEqual(2);
expect(
report.scoreAfter,
'scripted sweep should progress the objective — tune INPUT_SCRIPT to your level layout',
).toBeGreaterThan(report.scoreBefore);
});
assets/threejs-vite-game/tests/visual-regression.template.ts
import { expect, test, type Page } from '@playwright/test';
// Copy this file to tests/visual-regression.spec.ts when the game is stable
// enough for screenshot baselines. First run:
// npx playwright test tests/visual-regression.spec.ts --update-snapshots
// Then compare:
// npx playwright test tests/visual-regression.spec.ts
//
// REQUIREMENT: the game must implement window.__THREE_GAME_TEST_HOOKS__
// (see src/game/Game.ts installTestHooks and src/vite-env.d.ts). Without real
// hooks, baselines capture a live animating scene and every rerun diffs.
// prepareDeterministicScreenshot fails loudly if the hooks object is missing.
async function prepareDeterministicScreenshot(page: Page, stateName: string) {
await page.goto('/');
await page.waitForFunction(() => (window.__THREE_GAME_DIAGNOSTICS__?.frame ?? 0) > 10);
const hasHooks = await page.evaluate(() => Boolean(window.__THREE_GAME_TEST_HOOKS__));
if (!hasHooks) {
throw new Error(
'__THREE_GAME_TEST_HOOKS__ is not defined. Implement the deterministic test hooks ' +
'(seed/setState/setPausedForScreenshot/setReducedMotion/hideDebugUi) before ' +
'enabling visual baselines — see threejs-qa-release references/visual-test-harness.md.',
);
}
await page.evaluate((name) => {
const hooks = window.__THREE_GAME_TEST_HOOKS__;
hooks?.seed(12345);
hooks?.setReducedMotion(true);
hooks?.hideDebugUi(true);
hooks?.setState(name);
hooks?.setPausedForScreenshot(true);
}, stateName);
await page.waitForTimeout(150);
}
// State names must match the game's setState implementation. The scaffold
// supports 'active-play' and 'complete'; add baselines for your game's own
// states (fail/retry, menus, boss phases) as you implement them.
test('active play visual baseline', async ({ page }, testInfo) => {
await prepareDeterministicScreenshot(page, 'active-play');
await expect(page).toHaveScreenshot(`active-play-${testInfo.project.name}.png`, {
fullPage: true,
maxDiffPixelRatio: 0.015,
});
});
test('complete state visual baseline', async ({ page }, testInfo) => {
await prepareDeterministicScreenshot(page, 'complete');
await expect(page).toHaveScreenshot(`complete-${testInfo.project.name}.png`, {
fullPage: true,
maxDiffPixelRatio: 0.015,
});
});
assets/threejs-vite-game/tests/visual.spec.ts
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([]);
});
assets/threejs-vite-game/tsconfig.json
{
"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"]
}
assets/threejs-vite-game/vite.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,
},
});
references/checklists/endless-runner-premium-quality.md
# 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.
references/checklists/game-design-level-design.md
# Game Design And Level Design Checklist
Use before claiming a new game, major gameplay upgrade, level/encounter pass, premium gameplay, or polished gameplay is complete.
- A game design brief names player promise, target feeling, primary verb, objective, pressure, reward, fail/retry, and non-goals.
- The core loop is written as verb -> objective -> pressure -> reward/progression -> failure/retry.
- The loop is implemented through real input, not only described.
- The first playable minute includes at least one objective, one meaningful pressure source, one feedback/reward event, and one setback or fail/retry path when genre-appropriate.
- Player skill expression is explicit: what a better player does differently.
- Difficulty curve is named and tied to parameters, spawn tables, wave composition, speed, resource economy, or spatial constraints.
- Level/track/arena/map/wave/hole/puzzle plan includes start, first decision, first threat, first reward, landmarks, escalation, recovery beats, and failure readability.
- Greybox/blockout scale and routes are proven before expensive visual detail.
- Obstacles, enemies, rewards, and interactables are placed to create decisions, not random clutter.
- Camera framing supports the next decision in the planned space.
- UI/HUD communicates objective, pressure, reward, or fail state.
- Game-feel tuning covers control response, simulated space, camera, feedback, and restart speed.
- At least one active playtest loop was used to tune values.
- Fun-factor rejection tests have no unresolved blocker for the requested quality bar.
- Final report includes design brief, core loop contract, level/encounter plan, tuning notes, evidence, and remaining design risks.
references/checklists/game-feel.md
# Game Feel Checklist
Use before claiming a game feels good, is juicy, is polished, or is premium. Pairs with references/game-feel.md.
- The primary verb produces a visible response within 100ms of input.
- Every scoring, pickup, damage, and death event has at least one visual and one audio response.
- Screenshake is trauma-based with the `trauma²` curve, per-second decay, and a hard cap.
- Trauma magnitude scales with event weight (pickup subtle, explosion unmistakable).
- Hitstop scales the gameplay delta only; camera, shake, tweens, and HUD keep the real delta.
- Hitstop is reserved for heavy contact, not fired on every minor event.
- The render loop is never paused to create a freeze; only gameplay delta is scaled.
- Squash-and-stretch preserves volume and settles with an overshoot (easeOutBack).
- FOV punch calls updateProjectionMatrix() and decays back to base.
- Impact flash stores the material's base emissive value and tweens back to it.
- Feedback never obscures the next player decision (shake/flash/hitstop stay readable).
- Gamepad rumble is feature-detected and matched in strength to the event.
- Repeated audio samples use pitch/volume variance so they never sound identical.
- All gameplay and effect randomness routes through createSeededRandom, never Math.random.
- Time-based effects are driven by accumulated game time, not wall clock.
- Each core event maps to a full feedback stack (see the tuning table), not a single cue.
references/checklists/new-game-definition-of-done.md
# New Game Definition Of Done
- The project installs with `npm install`.
- `npm run dev` starts a local Vite server.
- `npm run build` completes.
- The first screen is the game, not a landing page.
- The player can interact within 5 seconds.
- A compact game design brief exists: player promise, primary verb, objective, pressure, reward, fail/retry, and non-goals.
- A level/encounter plan exists for the first playable space, track, arena, wave, hole, table, or puzzle.
- There is a clear objective, score, timer, health, level target, or fail condition.
- The core loop is proven through real input, not only described in text.
- The first playable minute includes at least one meaningful decision and one feedback/reward event.
- 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 design brief, level/encounter plan, controls, verification evidence, and remaining risks.
references/game-design-level-design.md
# Game Design And Level Design
Use this reference before broad new-game creation, major mechanic changes, progression tuning, level/arena/track/wave design, combat encounters, or any claim that gameplay is premium, polished, complete, or less generic.
This is not a long design document. It is a compact player-facing contract that turns an idea into implementable rules, spaces, pacing, and tuning checks.
Research basis: Unity's GDD guidance emphasizes player goals, rules/mechanics, difficulty, core loop, and feedback; MDA separates mechanics, runtime dynamics, and player experience; Unity's level-design material emphasizes concept, blockout/greybox, playtest, and iteration; Steve Swink's game-feel framing centers real-time control, simulated space, and polish.
## Design Brief Gate
Before implementation, write a brief with:
- Player promise: the fantasy in one sentence.
- Target feeling: tense, fast, tactical, elegant, chaotic, precise, cozy, etc.
- Primary verb: steer, aim, shoot, place, dodge, bank, sink, parry, solve.
- Secondary verbs: boost, upgrade, collect, block, drift, aim, reload, switch lane.
- Core loop: what the player repeats every 5-30 seconds.
- Progression loop: what changes across 1-5 minutes.
- Fail/retry loop: how the player loses, learns, and restarts.
- Scoring/economy: what is rewarded, what is spent, what creates risk.
- Skill expression: what a better player does differently.
- Readability promise: how the next decision is communicated.
- Non-goals: features intentionally out of scope for this slice.
Reject "explore a cool scene" as a design brief. A game brief must include player decisions, pressure, feedback, and consequence.
## Core Loop Contract
Write the loop as:
```text
Player does [verb] to achieve [objective] while [pressure] creates risk; success gives [reward/progression], failure causes [cost/retry].
```
Then prove the loop in code:
- The primary verb is mapped to real input.
- The objective is visible in the world or HUD.
- Pressure exists within the first playable minute.
- Reward/progression changes game state, not only visuals.
- Failure or setback teaches what happened.
- Restart/retry is fast enough to encourage another attempt.
## MDA Check
Use this as a compact design review:
- Mechanics: exact rules, controls, collisions, timers, spawn tables, physics, scoring.
- Dynamics: what happens when a real player uses those rules under pressure.
- Aesthetics: the intended feeling and whether screenshots/playtest evidence supports it.
If the intended feeling does not emerge from the current mechanics, change mechanics or level layout. Do not try to fix missing dynamics with graphics alone.
## Level And Encounter Plan
Before building a track, arena, map, wave set, table, or puzzle space, define:
- Spatial format: lane, circuit, arena, corridor, open field, table, grid, tower path, puzzle room.
- Camera contract: what the camera can and cannot see.
- Player start, first decision, first reward, first threat.
- Safe zone or learning space, if the genre needs one.
- Main route plus optional risk/reward route, if applicable.
- Landmarks or orientation anchors.
- Escalation: how challenge increases every 20-60 seconds or per wave/hole/lap/phase.
- Recovery beats: where the player can breathe or regain control.
- Failure readability: how hazards, attacks, misses, and penalties are telegraphed.
- Reuse plan: which pieces are modular, randomized, or parameterized.
Greybox first: use simple shapes to prove scale, route, timing, line-of-sight, collision, and pacing before investing in art detail.
## Genre Patterns
### Endless Runner
- Teach lanes or steering with an early safe segment.
- Alternate compression and release: dense hazard groups, then reward/visibility windows.
- Use at least three obstacle families with distinct silhouettes and telegraphs.
- Difficulty can ramp via speed, lane pressure, obstacle combinations, and reward placement.
### Arcade Racer
- Define handling fantasy first: drift-heavy, grip racing, hover glide, combat racer, rally.
- Track should have readable apexes, braking/drift cues, recovery width, landmarks, and route rhythm.
- Add skill tests: clean racing line, boost timing, drift angle, traffic threading, shortcut risk.
### Dogfight / Space Shooter
- Define engagement range, turn rate, projectile speed, lock-on/lead affordance, and escape options.
- Encounters need target readability, off-screen threat indicators, and moments to reacquire orientation.
- Use waves or objectives that force movement, not only circular chasing.
### Tower Defense
- Define path topology, chokepoints, build zones, enemy archetypes, tower roles, economy cadence, and wave tells.
- Good maps create placement decisions, not just optimal obvious tiles.
- Waves should test different tower roles and expose upcoming enemy types before punishment.
### Billiards / Pool / Snooker
- Physics and rules are the game design. Use readable shot aim, cue force, spin/english, turn state, legal target feedback, foul feedback, and camera reset.
- Level design is table readability: pockets, rails, ball colors, aim lines, shadows, and overhead/low camera options.
### Mini Golf
- Each hole should have one clear read, one trick, and one risk/reward route.
- Escalate via ramps, banks, moving blockers, portals, split paths, gravity changes, and timing windows.
- Avoid holes where the first shot outcome is unreadable from the tee.
### Boss Fight / Action Arena
- Define boss phases, telegraphs, recovery windows, player punish windows, arena hazards, and camera lock behavior.
- Every attack needs a readable tell, avoid/defend option, impact feedback, and cooldown.
- Phases should add combinations or arena pressure, not just more health.
### Puzzle / Physics Game
- State the rule being taught in each puzzle.
- First puzzle teaches, second confirms, third twists.
- Failure should reveal information. Avoid hidden dependency chains that require guessing.
## Difficulty And Pacing
Use a curve, not random escalation:
- Introduce one new concept at a time.
- Combine known concepts after they are understood.
- Add breathing space after high-pressure moments.
- Increase challenge through timing, density, speed, resource scarcity, enemy mix, or spatial constraints.
- Keep early failures recoverable unless the genre is intentionally harsh.
- Tune with named constants and record changes.
## Fun-Factor Rejection Tests
Reject or iterate if any are true:
- The first 30 seconds lack a real decision.
- The player can ignore the main mechanic and still progress.
- The objective is unclear without reading source code or instructions.
- Failure happens before the player can understand why.
- Challenge is only "more things" rather than better combinations.
- Rewards do not change strategy, score, progression, or feel.
- The space is decorative and does not shape decisions.
- The game is fun only in the designer's explanation, not in active play.
## Report Requirements
Report:
- Game design brief.
- Core loop contract.
- Level/encounter plan.
- Difficulty/pacing plan.
- Tuning constants changed.
- Fun-factor rejection tests passed or remaining failures.
- Evidence from active play, not just a screenshot.
references/game-feel.md
# Game Feel
Use this reference when a game "works" but does not feel good: the primary verb lands without weight, hits read as bookkeeping, pickups vanish silently. Game feel (juice) is the layer of response, contact feedback, and audio-visual sync that turns correct mechanics into satisfying ones.
Game feel is state communication, not decoration. Apply effects in this order of operations; each layer depends on the one before it:
1. Input latency: the primary verb must produce a visible response within ~100ms. Fix this first; no amount of juice hides a laggy control.
2. Response curves: acceleration, easing, and overshoot on the player's own motion.
3. Contact feedback: what happens at the moment of impact, pickup, or score (flash, squash, hitstop, shake).
4. Camera: shake, kick, FOV punch that amplify contact without hiding the field of play.
5. Audio-visual sync: sound fires on the same frame as the visual, with pitch/volume variance so repeats stay alive.
Readability rule: feedback must clarify game state, never obscure the next decision. If shake, flash, or hitstop hides the thing the player must react to next, it is a bug, not polish. Scale every effect so the strongest events (death, explosion) are unmistakable and the weakest (pickup) stay subtle.
All code below is copy-pasteable, strict-TS clean, dependency-free, and matches the scaffold (`update(delta, elapsed)`, class systems, `createSeededRandom`).
## Tween / Easing Helper
No external tween library. A tiny manager updated with `delta`, plus three curves that cover 90% of feel work.
```ts
export type Easing = (t: number) => number;
export const easeInQuad: Easing = (t) => t * t;
export const easeOutCubic: Easing = (t) => 1 - Math.pow(1 - t, 3);
export const easeOutBack: Easing = (t) => {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
};
interface ActiveTween {
elapsed: number;
duration: number;
easing: Easing;
onUpdate: (value: number) => void;
onComplete?: () => void;
}
export class TweenManager {
private readonly tweens: ActiveTween[] = [];
tween(
durationSec: number,
onUpdate: (value: number) => void,
easing: Easing = easeOutCubic,
onComplete?: () => void,
): void {
this.tweens.push({ elapsed: 0, duration: durationSec, easing, onUpdate, onComplete });
}
update(delta: number): void {
for (let i = this.tweens.length - 1; i >= 0; i -= 1) {
const t = this.tweens[i];
t.elapsed += delta;
const k = Math.min(t.elapsed / t.duration, 1);
t.onUpdate(t.easing(k));
if (t.elapsed >= t.duration) {
t.onComplete?.();
this.tweens.splice(i, 1);
}
}
}
}
```
Update the manager with the **real** delta (feedback must stay live during hitstop). `easeOutBack` returns values above 1 near the end; that overshoot is the bounce.
## Trauma-Based Screenshake
Add trauma on events; shake is `trauma²` so small events barely move the camera and big ones snap hard. Trauma decays linearly per second and is hard-capped. Drive the noise from accumulated game time (deterministic), not wall clock.
```ts
import * as THREE from 'three';
const TRAUMA_MAX = 1;
const TRAUMA_DECAY = 1.4; // trauma units per second
const MAX_OFFSET = 0.55; // world units at full shake
const MAX_ROLL = 0.1; // radians at full shake
// Deterministic value noise in [-1, 1]; per-axis seed keeps axes independent.
function pseudoNoise(t: number, seed: number): number {
const x = Math.sin(t * 12.9898 + seed * 78.233) * 43758.5453;
return (x - Math.floor(x)) * 2 - 1;
}
export class ShakeRig {
private trauma = 0;
private time = 0;
addTrauma(amount: number): void {
this.trauma = Math.min(TRAUMA_MAX, this.trauma + amount);
}
// Call every frame AFTER CameraRig has written the base transform.
update(delta: number, camera: THREE.PerspectiveCamera): void {
this.time += delta;
this.trauma = Math.max(0, this.trauma - TRAUMA_DECAY * delta);
if (this.trauma <= 0) return;
const shake = this.trauma * this.trauma;
const freq = this.time * 32;
camera.position.x += MAX_OFFSET * shake * pseudoNoise(freq, 1);
camera.position.y += MAX_OFFSET * shake * pseudoNoise(freq, 2);
camera.rotation.z += MAX_ROLL * shake * pseudoNoise(freq, 3);
}
}
```
Recommended trauma per event: pickup `0.15`, hit `0.4`, explosion `0.7`. Cap at `1.0` so stacked events cannot fling the camera. The additive offset does not accumulate because `CameraRig` re-derives position from the follow target and `lookAt` re-derives rotation every frame.
## Hitstop
A brief freeze on heavy contact sells weight. Scale the gameplay delta, never the render loop.
```ts
// Fields on Game:
private timeScale = 1;
private hitstopRemaining = 0;
hitstop(durationMs: number, scale = 0.05): void {
this.hitstopRemaining = Math.max(this.hitstopRemaining, durationMs / 1000);
this.timeScale = scale;
}
private update(delta: number, elapsed: number): void {
if (this.hitstopRemaining > 0) {
this.hitstopRemaining -= delta; // decay in REAL time
if (this.hitstopRemaining <= 0) this.timeScale = 1;
}
const gameplayDelta = delta * this.timeScale;
// Gameplay reads the scaled delta so the world crawls...
this.player.update(gameplayDelta, elapsed, this.input, this.tuning, ARENA);
for (const pickup of this.pickups) pickup.update(gameplayDelta, elapsed);
// ...but camera, shake, tweens, and HUD read the REAL delta so feedback stays live.
this.cameraRig.update(delta, this.player.group.position, this.tuning.cameraLag);
this.shakeRig.update(delta, this.camera);
this.tweens.update(delta);
}
```
Recommended: 60-90ms at `0.05` scale on heavy hits only. Never call `loop.stop()` or skip `requestAnimationFrame` to freeze — the frame must keep drawing so the frozen moment is visible. Only the gameplay delta is scaled.
## Squash-and-Stretch
Deform on impact or jump, then overshoot back with `easeOutBack`. Preserve volume: when one axis scales by `s`, scale the other two by `1 / sqrt(s)`.
```ts
// Impact squash (squashY < 1); jump stretch uses squashY > 1.
squash(target: THREE.Object3D, squashY = 0.85, durationSec = 0.18): void {
const startXZ = 1 / Math.sqrt(squashY); // volume-preserving counter-scale
this.tweens.tween(
durationSec,
(t) => {
const y = squashY + (1 - squashY) * t; // squashY -> 1
const xz = startXZ + (1 - startXZ) * t; // stretch -> 1
target.scale.set(xz, y, xz);
},
easeOutBack, // overshoot past 1 gives the bouncy settle
);
}
```
Use ~1.15 stretch on jump takeoff and ~0.9 squash on landing, both returning over ~180ms.
## Camera Kick / FOV Punch
An additive FOV bump reads as acceleration or shock. Decay it toward 0 with a ~200ms time constant.
```ts
private baseFov = 48;
private fovPunch = 0; // additive degrees
punchFov(degrees: number): void {
this.fovPunch = Math.min(10, this.fovPunch + degrees); // additive, clamped
}
private updateFov(delta: number): void {
if (this.fovPunch <= 0.001) return;
this.fovPunch *= Math.exp(-delta / 0.2);
if (this.fovPunch < 0.001) this.fovPunch = 0;
this.camera.fov = this.baseFov + this.fovPunch;
this.camera.updateProjectionMatrix(); // REQUIRED after any fov change
}
```
Recommended +4..8° on boost, dash, or hit. Forgetting `updateProjectionMatrix()` leaves the FOV visually unchanged.
## Impact Flash
Pulse `emissiveIntensity` on the hit material and tween it back. Store the base value once; the material's `emissive` color must be non-black for the pulse to show.
```ts
flashHit(material: THREE.MeshStandardMaterial, peak = 2.4, durationSec = 0.22): void {
const base = (material.userData.baseEmissive ??= material.emissiveIntensity);
this.tweens.tween(
durationSec,
(t) => {
material.emissiveIntensity = base + (peak - base) * (1 - t); // peak -> base
},
easeOutCubic,
);
}
```
For big events (explosion, death), add a one-frame full-screen white flash: a `position: fixed` DOM `<div>` with `pointer-events: none`, animated `opacity: 0.8 -> 0` over ~90-120ms via `element.animate(...)`. Cheaper than a render-target flash and it never touches the 3D pipeline.
## Pickup Pop
Collected items should pop, rise, and fade rather than blink out.
```ts
playPickupPop(mesh: THREE.Mesh, material: THREE.MeshStandardMaterial): void {
const startY = mesh.position.y;
material.transparent = true;
this.tweens.tween(
0.28,
(t) => {
mesh.scale.setScalar(1 + 0.6 * (1 - t)); // 1.6 -> 1.0
mesh.position.y = startY + t * 1.2; // rise
material.opacity = 1 - t; // fade
},
easeOutCubic,
() => {
mesh.visible = false;
},
);
}
```
Punch the HUD counter on the same event (add to `Hud`, reusing the scaffold's WAAPI pattern):
```ts
punchScore(): void {
this.scoreValue.animate(
[{ transform: 'scale(1.2)' }, { transform: 'scale(1)' }],
{ duration: 120, easing: 'ease-out' },
);
}
```
## Gamepad Rumble
Feature-detect `vibrationActuator`; `playEffect` returns a promise that may reject on unsupported hardware, so swallow it.
```ts
export function rumble(durationMs: number, strong = 0.6, weak = 0.3): void {
const pads = navigator.getGamepads?.() ?? [];
for (const pad of pads) {
const actuator = pad?.vibrationActuator;
if (!actuator) continue; // optional chaining covers older browsers
void actuator.playEffect('dual-rumble', {
duration: durationMs,
strongMagnitude: strong,
weakMagnitude: weak,
});
}
}
```
Match rumble to the hit: light (180ms, 0.3) on pickups, heavy (250ms, 0.9) on death or explosion.
## Audio Feel Coupling
Identical repeated samples feel cheap: the ear detects the exact repeat as artificial (the "machine-gun" effect). Vary pitch per playback, and duck ambient/music while hitstop holds so the impact reads.
```ts
// AudioSystem: pitch-vary each shot; duck holds during hitstop.
private duck = 1;
play(buffer: AudioBuffer, rng: () => number, baseGain = 0.8): void {
if (!this.context || this.context.state !== 'running') return;
const source = this.context.createBufferSource();
const gain = this.context.createGain();
source.buffer = buffer;
source.playbackRate.value = 1 + (rng() - 0.5) * 0.12; // +/-6% pitch
gain.gain.value = baseGain * this.duck;
source.connect(gain).connect(this.context.destination);
source.start();
}
setDuck(value: number): void {
this.duck = value; // e.g. 0.6 while hitstop is active, 1 otherwise
}
```
Pass `this.rng` (the seeded RNG) as the `rng` argument so pitch variance stays deterministic under test.
## Tuning Table
Map each event to a full feedback stack. Stronger events get more layers and higher magnitudes.
| Event | Feedback stack |
| -------------- | ------------------------------------------------------------------------------ |
| Pickup | pickup pop + HUD counter punch + pitch-varied chime + `0.15` trauma |
| Player hit | hitstop 70ms + `0.4` trauma + impact flash + rumble (180ms) + HUD pulse |
| Enemy killed | hitstop 40ms + `0.3` trauma + impact flash + pitch-varied boom |
| Boost / dash | FOV punch +6° + stretch (1.15) + whoosh + light rumble |
| Jump / land | stretch on takeoff, squash (0.9) on landing + step audio + `0.2` trauma on land |
| Explosion | hitstop 90ms + `0.7` trauma + white overlay flash + FOV punch +8° + heavy rumble |
## Anti-Patterns
- Constant camera shake, or shake with no decay: nauseating and it hides the play field.
- Trauma added without the `trauma²` curve or the hard cap: small events feel violent, stacked events fling the camera.
- Hitstop on every minor event: the game feels laggy instead of weighty. Reserve it for heavy contact.
- Feedback that blocks or delays input: never gate the primary verb behind an animation finishing.
- Effects driven by wall clock or fixed-timestep gameplay delta: they desync from the simulation and break during hitstop or on frame drops. Drive time-based effects from accumulated game time; drive feedback tweens/camera from the real render delta.
- Emissive flash on a material with a black `emissive` color: nothing shows.
- `Math.random` in any gameplay or effect path: it breaks the deterministic test hooks and screenshot baselines.
## Determinism
Route **all** randomness — pitch variance, spawn jitter, shake seeds if you seed them — through `createSeededRandom` (see `src/utils/random.ts`), never `Math.random`. Drive time-based effects (shake noise, ambient motion) from accumulated game time passed through `update(delta, elapsed)`, not `performance.now()` or `Date.now()`. This keeps the `seed()` and `setReducedMotion()` test hooks reproducible so visual baselines and bot playtests stay stable.
references/gameplay-workflows.md
# Gameplay Workflows
Use this reference for first playable slices, architecture, mechanics, entities, controls, camera, physics, audio hooks, and game-feel iteration. For broad game creation, level/arena/track/wave/hole/puzzle work, encounter design, progression, difficulty, or premium gameplay claims, also load `references/game-design-level-design.md`.
## 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 design brief and core loop contract from `references/game-design-level-design.md`.
3. Define the level/encounter plan: first decision, first threat, first reward, escalation, recovery beats, and readability.
4. 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
5. Add diagnostics when possible:
- `window.__THREE_GAME_DIAGNOSTICS__`
- renderer info
- game state snapshot
- input state
- active entity counts
6. Verify build, browser, console/page errors, screenshot, nonblank canvas, and one real input path.
Reject a slice that cannot be controlled or restarted. Also reject a slice where the level/arena/track/wave/table/puzzle is purely decorative and does not shape the player's decisions.
## 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:
```text
input -> fixed physics if any -> gameplay systems -> animation/VFX -> camera -> UI bridge -> render
```
Do 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 `GLTFLoader` from `three/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 `AnimationMixer` for rigged/animated GLBs and update mixers with `deltaSeconds`.
- 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-action` intentional 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.
## Design And Level Iteration
When a prototype is technically playable but bland, iterate the design before adding more art:
- Tighten the player promise and primary verb.
- Add a decision in the first 30 seconds.
- Move hazards/rewards so the player chooses between safety, speed, score, or resource gain.
- Add a learning beat before a punishing combination.
- Add one recovery beat after high pressure.
- Replace random placement with authored pacing rules or seeded patterns.
- Tune the camera to frame the next decision, not only the player object.
- Report what changed in the design brief, level plan, or difficulty curve.
## 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 build` or 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.
references/physics-engine-selection.md
# 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:
```bash
npm install @dimforge/rapier3d-compat
```
Initialize once before gameplay starts:
```ts
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:
```ts
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:
```ts
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:
```ts
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:
```ts
const fastBody = world.createRigidBody(
RAPIER.RigidBodyDesc.dynamic().setCcdEnabled(true)
);
```
For sensors/triggers:
```ts
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 with `RAPIER.init()`, create rigid bodies and colliders, use forces/impulses/damping/CCD, and enable collision events on colliders.
references/prompt-templates.md
# 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.
---
# Game Design And Level Design Prompt
Use `threejs-gameplay-systems` to design or improve this game's player-facing loop before implementation:
Game:
- Target fantasy / player promise:
- Primary verb:
- Desired feeling:
- Genre:
- Target session length:
- Target devices:
Produce and then implement:
- Compact game design brief.
- Core loop contract: verb -> objective -> pressure -> reward/progression -> fail/retry.
- Level/encounter plan: start, first decision, first threat, first reward, landmarks, escalation, recovery beats, readability.
- Difficulty curve and tuning constants.
- Fun-factor rejection tests.
Constraints:
- Build a playable loop first.
- Do not treat a static scene as a game.
- Make the level/arena/track/wave/hole/puzzle shape player decisions.
- Verify with real input, active-play screenshot, canvas-pixel check, and fail/retry or setback path when relevant.
scripts/create_threejs_game.py
#!/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:]))