agents/openai.yaml
interface:
display_name: "Three.js Gameplay Systems"
short_description: "Design and build playable Three.js systems"
default_prompt: "Use $threejs-gameplay-systems to design and implement this game's loop, levels, controls, and physics."
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" />
<link rel="icon" href="data:," />
<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',
// Reuse a dev server that is already running for the canvas inspector;
// CI always starts a fresh one.
reuseExistingServer: !process.env.CI,
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 },
},
},
{
// iPhone viewport, DPR and touch on the same GPU-backed Chromium as the
// inspector. devices['iPhone 13'] alone selects WebKit, which needs its
// own browser download and renders headless without the GPU. Confirm
// Safari-specific behavior on a real device.
name: 'mobile-chrome',
use: {
...devices['iPhone 13'],
defaultBrowserType: 'chromium',
channel: 'chromium',
},
},
],
});
assets/threejs-vite-game/scripts/inspect-threejs-canvas.mjs
#!/usr/bin/env node
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { existsSync, realpathSync } from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
// 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 },
};
// Browser errors kept per report: unique messages, truncated. Totals are always counted.
const MAX_ERRORS = 20;
const MAX_ERROR_LENGTH = 500;
const USAGE =
'Usage: inspect-threejs-canvas.mjs [--url URL] [--out DIR] [--mobile] [--wait MS] [--state NAME] [--seed N] [--run-id ID] [--json]\n' +
' inspect-threejs-canvas.mjs --manifest artifacts/evidence.json [--url URL] [--seed N] [--wait MS] [--json]\n' +
' --manifest captures every declared viewport/state in one browser, writing each report to its declared path.\n' +
' Prints one summary line per capture; full reports are written to disk. --json prints the full reports instead.\n' +
' --wait defaults to 0ms for named states (already frozen) and 750ms for current-view captures.\n' +
' --state requires setState(NAME) to return or resolve {state: NAME}; unknown states must throw.\n' +
' Named captures also require setPausedForScreenshot: stop simulation immediately, keep rendering.\n' +
' --seed requires a seed(N) hook; both hooks are awaited before capture.\n' +
' Preparation, including --wait, hooks, fonts and render frames, has a 10000ms deadline.\n' +
' State names and run IDs use 1-128 letters, digits, dots, underscores or hyphens, starting with a letter or digit.';
function validateIdentifier(value, flag) {
if (typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)) {
throw new Error(`${flag} must be a safe 1-128 character identifier starting with a letter or digit`);
}
}
export function parseArgs(argv) {
const args = {
url: 'http://127.0.0.1:5188',
out: 'artifacts/canvas-inspection',
mobile: false,
wait: null,
state: null,
seed: undefined,
runId: null,
manifest: null,
json: false,
help: false,
};
const singleCaptureFlags = [];
for (let i = 0; i < argv.length; i += 1) {
const value = argv[i];
const takeValue = () => {
const next = argv[++i];
if (typeof next !== 'string' || !next.trim() || next.startsWith('--')) {
throw new Error(`Missing value for ${value}`);
}
return next;
};
if (value === '--url') args.url = takeValue();
else if (value === '--out') { args.out = takeValue(); singleCaptureFlags.push(value); }
else if (value === '--mobile') { args.mobile = true; singleCaptureFlags.push(value); }
else if (value === '--wait') args.wait = Number(takeValue());
else if (value === '--state') { args.state = takeValue(); singleCaptureFlags.push(value); }
else if (value === '--seed') args.seed = Number(takeValue());
else if (value === '--run-id') { args.runId = takeValue(); singleCaptureFlags.push(value); }
else if (value === '--manifest') args.manifest = takeValue();
else if (value === '--json') args.json = true;
else if (value === '-h' || value === '--help') args.help = true;
else {
throw new Error(`Unknown argument: ${value}`);
}
}
if (args.state !== null) validateIdentifier(args.state, '--state');
if (args.runId !== null) validateIdentifier(args.runId, '--run-id');
if (args.seed !== undefined && !Number.isSafeInteger(args.seed)) {
throw new Error('--seed must be a safe integer');
}
if (args.wait !== null && (!Number.isFinite(args.wait) || args.wait < 0 || args.wait > 2_147_483_647)) {
throw new Error('--wait must be finite non-negative milliseconds within the timer range');
}
if (!['http:', 'https:'].includes(new URL(args.url).protocol)) {
throw new Error('--url must use http or https');
}
if (args.manifest !== null && singleCaptureFlags.length > 0) {
throw new Error(`--manifest declares each capture; remove ${singleCaptureFlags.join(', ')}`);
}
return args;
}
// Named states are frozen before settling, so they need no extra wait by default.
export function settleWait(wait, state) {
return wait ?? (state ? 0 : 750);
}
export function readManifestCaptures(manifest) {
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
throw new Error('manifest must be a JSON object');
}
if (typeof manifest.runId !== 'string' || !manifest.runId.trim()) throw new Error('manifest requires a runId');
validateIdentifier(manifest.runId, 'manifest runId');
if (!Array.isArray(manifest.captures) || manifest.captures.length === 0) {
throw new Error('manifest requires a nonempty captures list');
}
return manifest.captures.map((capture, index) => {
if (!capture || typeof capture !== 'object') throw new Error(`capture ${index} must be an object`);
const { mode, state, report } = capture;
if (mode !== 'desktop' && mode !== 'mobile') throw new Error(`capture ${index} mode must be desktop or mobile`);
if (!('state' in capture)) throw new Error(`capture ${index} requires state (a name or null)`);
if (state !== null) validateIdentifier(state, `capture ${index} state`);
if (typeof report !== 'string' || !report.endsWith('.json')) {
throw new Error(`capture ${index} report must be a .json path`);
}
return { mobile: mode === 'mobile', state, runId: manifest.runId, reportPath: report,
screenshotPath: report.replace(/\.json$/, '.png') };
});
}
export async function loadDependency(name, cwd = process.cwd()) {
const project = path.resolve(cwd);
const resolvers = [createRequire(import.meta.url), createRequire(path.join(project, 'package.json'))];
let missing;
for (const require of resolvers) {
let resolved;
try {
resolved = require.resolve(name);
} catch (error) {
if (error.code !== 'MODULE_NOT_FOUND') throw error;
missing = error;
continue;
}
// Import outside the catch: a broken installed package is not a missing dependency.
const dependency = await import(pathToFileURL(resolved).href);
// createRequire can select a CommonJS entrypoint with only a default export.
return { ...dependency.default, ...dependency };
}
throw new Error(
`Missing inspector dependency "${name}". Install @playwright/test and pngjs in the game project ` +
`(${project}), then run the inspector from that directory.`,
{ cause: missing },
);
}
async function runPreparation(page, { state = null, seed, timeoutMs = 10_000, wait = 0 }, capture) {
if (state !== null) validateIdentifier(state, '--state');
if (seed !== undefined && !Number.isSafeInteger(seed)) throw new Error('--seed must be a safe integer');
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
throw new Error('Preparation timeout must be positive milliseconds within the timer range');
}
if (!Number.isFinite(wait) || wait < 0 || wait > 2_147_483_647) {
throw new Error('--wait must be finite non-negative milliseconds within the timer range');
}
if (!capture && state === null && seed === undefined) return { requestedState: null, appliedState: null };
const message = `${capture ? 'Capture preparation' : 'Test hooks'} did not finish within ${timeoutMs}ms`;
const deadline = Date.now() + timeoutMs;
let hostTimer;
try {
// A host-side deadline also covers a stalled evaluate call or blocked browser event loop.
return await Promise.race([
new Promise((_, reject) => {
hostTimer = setTimeout(() => reject(new Error(message)), timeoutMs);
}),
page.evaluate(async ({ state, seed, capture, wait, deadline, message }) => {
const hooks = window.__THREE_GAME_TEST_HOOKS__;
const namedCapture = capture && state !== null;
if ((state !== null || seed !== undefined) && !hooks) {
throw new Error('--state/--seed requires window.__THREE_GAME_TEST_HOOKS__');
}
if (state !== null && typeof hooks.setState !== 'function') {
throw new Error('--state requires a setState function');
}
if (seed !== undefined && typeof hooks.seed !== 'function') {
throw new Error('--seed requires a seed function');
}
if (namedCapture && typeof hooks.setPausedForScreenshot !== 'function') {
throw new Error('--state capture requires a setPausedForScreenshot function that stops simulation immediately and keeps rendering');
}
let expired = false;
let timer;
let settleTimer;
let frameId;
const checkDeadline = () => {
if (expired || Date.now() >= deadline) throw new Error(message);
};
const step = async (operation) => {
checkDeadline();
const result = await operation();
checkDeadline();
return result;
};
try {
return await Promise.race([
new Promise((_, reject) => {
timer = setTimeout(() => {
expired = true;
reject(new Error(message));
}, Math.max(0, deadline - Date.now()));
}),
(async () => {
if (namedCapture) await step(() => hooks.setPausedForScreenshot(false));
if (seed !== undefined) await step(() => hooks.seed(seed));
if (state !== null) {
const acknowledgement = await step(() => hooks.setState(state));
if (!acknowledgement || typeof acknowledgement !== 'object' ||
Array.isArray(acknowledgement) || acknowledgement.state !== state) {
throw new Error(`setState(${JSON.stringify(state)}) must acknowledge {state: ${JSON.stringify(state)}}`);
}
// Freeze in this evaluation immediately after setup, before any settling or render wait.
if (namedCapture) await step(() => hooks.setPausedForScreenshot(true));
}
if (capture) {
if (namedCapture && typeof hooks.setReducedMotion === 'function') {
await step(() => hooks.setReducedMotion(true));
}
if (namedCapture && typeof hooks.hideDebugUi === 'function') {
await step(() => hooks.hideDebugUi(true));
}
if (wait > 0) await step(() => new Promise((resolve) => { settleTimer = setTimeout(resolve, wait); }));
if (document.fonts) await step(() => document.fonts.ready);
await step(() => new Promise((resolve) => {
frameId = requestAnimationFrame(() => {
if (!expired) frameId = requestAnimationFrame(resolve);
});
}));
}
return { requestedState: state, appliedState: state };
})(),
]);
} finally {
expired = true;
clearTimeout(timer);
clearTimeout(settleTimer);
if (frameId !== undefined) cancelAnimationFrame(frameId);
}
}, { state, seed, capture, wait, deadline, message }),
]);
} finally {
clearTimeout(hostTimer);
}
}
export async function applyTestHooks(page, args = {}) {
return runPreparation(page, args, false);
}
export async function prepareCapture(page, args = {}) {
return runPreparation(page, args, true);
}
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() {
const { chromium } = await loadDependency('@playwright/test');
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 --no-shell 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),
};
}
function cropImage(png, rect) {
const clamp = (value, max) => Math.min(max, Math.max(0, Math.round(value)));
const x0 = clamp(rect.x, png.width);
const y0 = clamp(rect.y, png.height);
const width = clamp(rect.x + rect.width, png.width) - x0;
const height = clamp(rect.y + rect.height, png.height) - y0;
const data = Buffer.alloc(Math.max(0, width * height * 4));
for (let y = 0; y < height; y += 1) {
const from = ((y0 + y) * png.width + x0) * 4;
png.data.copy(data, y * width * 4, from, from + width * 4);
}
return { width, height, data };
}
// One CSS-scale full-page screenshot serves as both the review image and the
// canvas sample: a device-pixel mobile capture costs several times the image
// tokens and screenshot time for no extra evidence at this sampling density.
async function captureCanvas(page, mode, screenshotPath) {
const { PNG } = await loadDependency('pngjs');
const layout = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
const box = canvas?.getBoundingClientRect();
return {
rect: box ? { x: box.left + window.scrollX, y: box.top + window.scrollY, width: box.width, height: box.height } : null,
drawingBuffer: canvas ? { width: canvas.width, height: canvas.height } : null,
game: window.__THREE_GAME_DIAGNOSTICS__ ?? null,
};
});
await mkdir(path.dirname(screenshotPath), { recursive: true });
const buffer = await page.screenshot({ path: screenshotPath, fullPage: true, scale: 'css' });
const { rect } = layout;
if (!rect || rect.width < 32 || rect.height < 32) {
return { ok: false, reason: 'canvas-too-small', rect };
}
const png = cropImage(PNG.sync.read(buffer), rect);
if (png.width < 32 || png.height < 32) return { ok: false, reason: 'canvas-too-small', rect };
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 ok = alphaPixels > 256 && (variance > 8 || colors.size > 3);
return {
ok,
reason: ok ? 'nonblank' : 'low-variance',
rect,
drawingBuffer: layout.drawingBuffer,
alphaPixels,
variance,
colorBuckets: colors.size,
metrics: computePixelMetrics(png),
renderBudget: checkRenderBudget(layout.game?.renderer ?? null, mode),
diagnostics: layout.game,
};
}
function recordError(list, report, countField, text) {
report[countField] += 1;
const message = text.length > MAX_ERROR_LENGTH ? `${text.slice(0, MAX_ERROR_LENGTH)}…` : text;
if (list.length < MAX_ERRORS && !list.includes(message)) list.push(message);
}
export async function inspectPage(page, args) {
const consoleErrors = [];
const pageErrors = [];
const mode = args.mobile ? 'mobile' : 'desktop';
const baseName = args.state ? `${mode}-${args.state}` : mode;
const report = {
url: args.url,
mode,
state: null,
requestedState: args.state ?? null,
appliedState: null,
runId: args.runId ?? null,
seed: args.seed ?? null,
screenshotPath: null,
gpu: null,
result: null,
consoleErrors,
pageErrors,
consoleErrorCount: 0,
pageErrorCount: 0,
};
page.on('console', (message) => {
if (message.type() === 'error') recordError(consoleErrors, report, 'consoleErrorCount', message.text());
});
page.on('pageerror', (error) => recordError(pageErrors, report, 'pageErrorCount', error.message));
try {
// 'load' instead of 'networkidle': named states await their own assets, and
// networkidle adds a fixed idle window to every capture.
await page.goto(args.url, { waitUntil: 'load' });
await page.waitForSelector('canvas', { state: 'visible', timeout: 10_000 });
const applied = await prepareCapture(page, { ...args, wait: settleWait(args.wait, args.state) });
report.state = applied.appliedState;
report.appliedState = applied.appliedState;
report.gpu = await readGpuInfo(page);
const screenshotPath = args.screenshotPath ?? path.join(args.out, `${baseName}.png`);
report.result = await captureCanvas(page, mode, screenshotPath);
report.screenshotPath = screenshotPath;
if (report.gpu.softwareRendered) {
console.error(
`warning: this run rasterized on ${report.gpu.renderer} (software). Pixel and budget ` +
'checks remain valid; any FPS or frame-time reading from it does not.',
);
}
} catch (error) {
report.result = { ok: false, reason: 'capture-failed', error: error instanceof Error ? error.message : String(error) };
}
return report;
}
function contextOptions(devices, mobile) {
return mobile
? { ...devices['iPhone 13'], userAgent: undefined }
: { viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 };
}
function reportPassed(report) {
return report.result?.ok === true && report.consoleErrorCount === 0 && report.pageErrorCount === 0;
}
// One line per capture keeps agent-visible output small; the full report is on disk.
export function summarize(report, reportPath) {
const result = report.result ?? {};
const parts = [reportPassed(report) ? 'PASS' : 'FAIL', report.mode, report.requestedState ?? 'current-view'];
if (report.runId) parts.push(`run=${report.runId}`);
if (!result.ok) {
parts.push(`reason=${result.reason}`);
if (result.error) parts.push(`error=${JSON.stringify(result.error.slice(0, 300))}`);
}
if (report.gpu?.renderer) parts.push(report.gpu.softwareRendered ? 'gpu=SOFTWARE(fps-invalid)' : 'gpu=hardware');
const metrics = result.metrics;
if (metrics) {
parts.push(`entropy=${metrics.colorEntropyBits} edges=${metrics.edgeDensity} ` +
`contrast=${metrics.luminance.contrast} dominant=${metrics.dominantColorShare}`);
}
if (result.renderBudget) {
const over = result.renderBudget.rows.filter((row) => row.ok === false)
.map((row) => `${row.metric}:${row.actual}>${row.limit}`);
parts.push(over.length ? `over-budget=${over.join(',')}` : 'budget=ok');
}
for (const [field, count] of [['consoleErrors', 'consoleErrorCount'], ['pageErrors', 'pageErrorCount']]) {
if (report[count] > 0) {
parts.push(`${field}=${report[count]} first=${JSON.stringify(report[field][0].slice(0, 200))}`);
}
}
parts.push(`report=${reportPath}`);
if (report.screenshotPath) parts.push(`png=${report.screenshotPath}`);
return parts.join(' ');
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(USAGE);
return;
}
const mode = args.mobile ? 'mobile' : 'desktop';
const captures = args.manifest
? readManifestCaptures(JSON.parse(await readFile(args.manifest, 'utf8')))
: [{ mobile: args.mobile, state: args.state, runId: args.runId,
reportPath: path.join(args.out, `${args.state ? `${mode}-${args.state}` : mode}.json`) }];
const { devices } = await loadDependency('@playwright/test');
const browser = await launchBrowser();
const reports = [];
try {
for (const capture of captures) {
const context = await browser.newContext(contextOptions(devices, capture.mobile));
let report;
try {
report = await inspectPage(await context.newPage(), { ...args, ...capture });
} finally {
await context.close();
}
await mkdir(path.dirname(capture.reportPath), { recursive: true });
await writeFile(capture.reportPath, `${JSON.stringify(report, null, 2)}\n`);
reports.push(report);
if (!args.json) console.log(summarize(report, capture.reportPath));
if (!reportPassed(report)) process.exitCode = 1;
}
} finally {
await browser.close();
}
if (args.json) console.log(JSON.stringify(args.manifest ? reports : reports[0], null, 2));
}
if (process.argv[1] && existsSync(process.argv[1]) &&
import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
main().catch((error) => {
console.error(error);
process.exitCode = 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 restRotationY = 0;
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(rotationY = 0): void {
this.active = true;
this.group.visible = true;
this.restRotationY = rotationY;
this.stabilizeVisuals();
}
stabilizeVisuals(): void {
this.group.rotation.set(0, this.restRotationY, 0);
this.group.children[0].rotation.set(0, 0, 0);
this.group.position.y = 0.78 + Math.sin(this.index) * 0.16;
}
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);
}
reset(): void {
this.group.position.set(0, 0, 0);
this.group.rotation.set(0, 0, 0);
this.velocity.set(0, 0, 0);
this.move.set(0, 0);
this.targetVelocity.set(0, 0, 0);
this.stabilizeVisuals();
}
stabilizeVisuals(): void {
this.group.position.y = 0.06;
}
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' && name !== 'complete') throw new Error(`Unknown test state: ${name}`);
this.resetRun();
if (name === 'complete') this.completeRun();
this.render();
this.publishDiagnostics();
return { state: name };
},
setPausedForScreenshot: (paused: boolean) => {
this.pausedForScreenshot = paused;
},
setReducedMotion: (enabled: boolean) => {
this.reducedMotion = enabled;
if (enabled) {
this.player.stabilizeVisuals();
for (const pickup of this.pickups) pickup.stabilizeVisuals();
}
this.render();
this.publishDiagnostics();
},
hideDebugUi: (hidden: boolean) => {
this.debugTools.setHidden(hidden);
},
};
}
private resetRun(): void {
this.score = 0;
this.elapsed = 0;
this.complete = false;
this.player.reset();
for (const pickup of this.pickups) {
pickup.reset(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 | Promise<void>;
/** Acknowledge after setup/assets are ready; throw for unknown states. */
setState(name: string): { state: string } | Promise<{ state: string }>;
/** Stop simulation/state transitions immediately; keep rendering. Await optional synchronization. */
setPausedForScreenshot(paused: boolean): void | Promise<void>;
/** Stabilize ambient/idle visuals without requiring an unpaused simulation tick. */
setReducedMotion(enabled: boolean): void | Promise<void>;
/** Hide debug UI (lil-gui) before capturing. */
hideDebugUi(hidden: boolean): void | Promise<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);
const acknowledgement = await page.evaluate(async () => {
const hooks = window.__THREE_GAME_TEST_HOOKS__;
if (!hooks || typeof hooks.seed !== 'function' || typeof hooks.setState !== 'function') {
throw new Error('Bot playtests require seed and setState test hooks');
}
await hooks.seed(12345);
const applied = await hooks.setState('active-play');
if (!applied || applied.state !== 'active-play') throw new Error('setState must acknowledge active-play');
return applied;
});
expect(acknowledgement.state, 'bot must start in the requested state').toBe('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 acknowledgement = await page.evaluate(async (name) => {
const hooks = window.__THREE_GAME_TEST_HOOKS__;
if (!hooks) throw new Error('__THREE_GAME_TEST_HOOKS__ is required for visual baselines');
for (const key of ['seed', 'setState', 'setPausedForScreenshot', 'setReducedMotion', 'hideDebugUi'] as const) {
if (typeof hooks[key] !== 'function') throw new Error(`Visual baselines require the ${key} hook`);
}
await hooks.setPausedForScreenshot(false);
await hooks.seed(12345);
const applied = await hooks.setState(name);
if (!applied || applied.state !== name) throw new Error(`setState must acknowledge {state: ${JSON.stringify(name)}}`);
// Preserve the acknowledged state while optional hooks, fonts and rendering settle.
await hooks.setPausedForScreenshot(true);
await hooks.setReducedMotion(true);
await hooks.hideDebugUi(true);
await document.fonts.ready;
const renderFrames = () => new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
await renderFrames();
return applied;
}, stateName);
expect(acknowledgement.state, 'requested visual state must be applied').toBe(stateName);
}
// 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/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/genre-design.md
# Genre Design Patterns
Per-genre design vocabulary, difficulty shaping, and the tests that separate a real game from a scene you can move around in. The design brief, core loop contract, and level plan templates live in this skill's `SKILL.md` under "Design first".
## Contents
- Genre patterns (runner, racer, dogfight, tower defense, billiards, mini golf, boss, puzzle)
- Difficulty and pacing
- Fun-factor tests
## Genre patterns
**Endless runner** — teach lanes or steering with an early safe segment. Alternate compression and release: dense hazard groups, then reward and visibility windows. Use distinct silhouettes and telegraphs for each obstacle role; choose the number of families from the planned decisions and scope. Ramp difficulty through speed, lane pressure, obstacle combinations, and reward placement.
**Arcade racer** — define the handling fantasy first (drift-heavy, grip, hover glide, combat, rally). Tracks need readable apexes, braking and drift cues, recovery width, landmarks, and route rhythm. Skill tests: racing line, boost timing, drift angle, traffic threading, shortcut risk.
**Dogfight / space shooter** — define engagement range, turn rate, projectile speed, lead or lock-on affordance, and escape options. Encounters need target readability, off-screen threat indicators, and moments to reacquire orientation. Waves should force movement rather than circular chasing.
**Tower defense** — define path topology, chokepoints, build zones, enemy archetypes, tower roles, economy cadence, and wave tells. Good maps create placement decisions rather than one obvious optimal tile. Waves test different tower roles and reveal upcoming enemy types before punishing.
**Billiards / pool / snooker** — physics and rules *are* the design. Readable shot aim, cue force, spin, turn state, legal-target feedback, foul feedback, camera reset. Level design is table readability: pockets, rails, ball colors, aim lines, shadows, overhead and low camera options.
**Mini golf** — each hole gets one clear read, one trick, and one risk/reward route. Escalate through ramps, banks, moving blockers, portals, split paths, gravity changes, timing windows. The first shot's outcome should be readable from the tee.
**Boss fight / action arena** — define phases, telegraphs, recovery windows, player punish windows, arena hazards, camera lock behavior. Every attack needs a readable tell, an avoid or defend option, impact feedback, and a cooldown. Later phases add combinations or arena pressure, not just health.
**Puzzle / physics** — state the rule each puzzle teaches. First puzzle teaches, second confirms, third twists. Failure should reveal information; avoid hidden dependency chains that require guessing.
## Difficulty and pacing
A curve, not random escalation: introduce one new concept at a time, combine known concepts once they are understood, and leave breathing space after high-pressure moments. Raise challenge through timing, density, speed, resource scarcity, enemy mix, or spatial constraint. Keep early failures recoverable unless the genre is deliberately harsh. Tune with named constants and record what changed.
Greybox first — simple shapes prove scale, route, timing, line-of-sight, collision, and pacing before art detail is worth building.
## Fun-factor tests
Iterate if any of these is true:
- The first 30 seconds contain no real decision.
- The player can ignore the main mechanic and still progress.
- The objective is unclear without reading the source.
- Failure happens before the player can understand why.
- Challenge is "more things" rather than better combinations.
- Rewards change nothing about strategy, score, progression, or feel.
- The space is decorative and does not shape decisions.
- The game is fun in the explanation but not in active play.
If the intended feeling does not emerge from the current mechanics, change mechanics or layout. Graphics do not fix missing dynamics.
references/physics-engine-selection.md
# Physics Engine Selection
Three.js is a renderer. Physics runs in a separate world at a fixed timestep; mesh transforms are synchronized from body transforms afterwards.
## The ladder
1. **Custom collision** — arcade triggers, pickups, lanes, bullets, simple overlap tests, deterministic rails, endless runners, lane dodgers, transform-driven racers, sphere/box dogfights. Anywhere authored feel matters more than simulation.
2. **Rapier** — the default for serious browser games with real physics: mini golf, pool/snooker, pinball, marble racers, physics puzzles, platformers, moving platforms, ramps and slopes, character controllers, rigid-body stacks, destructible props, high-speed bodies needing CCD, sensor-heavy games.
3. **cannon-es** — lightweight JS-only fallback for small rigid-body scenes where avoiding WASM matters and collision complexity is low.
4. **Jolt** — advanced rigid-body work where its behavior is specifically wanted and WASM integration complexity is acceptable.
5. **Ammo.js/Bullet** — only when a project already depends on it.
6. **Matter.js** — 2D only.
Rapier is a Rust/WASM engine with official JS bindings: rigid bodies, colliders, sensors, collision events, forces/impulses, damping, axis locking, sleeping, and CCD.
Never collide against detailed visual meshes. Use primitive colliders, compound colliders, convex hulls, simplified triangle meshes for fixed level geometry only, and explicit sensor volumes. Imported GLB meshes get their own collision proxies.
## Rapier setup
```bash
npm install @dimforge/rapier3d-compat
```
```ts
import RAPIER from '@dimforge/rapier3d-compat';
await RAPIER.init();
const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
```
Fixed timestep with a clamped accumulator — the clamp is what keeps a tab-switch or a frame spike from spiralling:
```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;
}
}
```
Bodies and colliders:
```ts
const body = world.createRigidBody(
RAPIER.RigidBodyDesc.dynamic()
.setTranslation(0, 2, 0)
.setLinearDamping(0.25)
.setAngularDamping(0.5)
);
world.createCollider(
RAPIER.ColliderDesc.ball(0.5).setRestitution(0.6).setFriction(0.4),
body
);
```
Sync in exactly 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);
```
Fast bodies need CCD explicitly (it costs extra, so use it only where tunneling is real):
```ts
RAPIER.RigidBodyDesc.dynamic().setCcdEnabled(true)
```
Sensors are silent without active events — this is the single most common trigger bug:
```ts
const sensor = RAPIER.ColliderDesc.ball(1)
.setSensor(true)
.setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS);
```
## Architecture
- Physics ownership lives in `systems/PhysicsWorld` or `systems/CollisionSystem`, never in render code.
- Entities hold body handles and dispose them on restart.
- Update order: input intents → fixed physics → game state/collisions → VFX/camera/UI → render.
- Kinematic bodies for moving platforms and scripted obstacles; sensors for pickups, goals, holes, portals, checkpoints, damage zones.
- Keep a debug overlay for colliders, body counts, contact pairs, and velocity.
- Use sleeping and explicit body removal to avoid stale simulation state.
## Tuning
Tune in units that map cleanly to scene scale, with named constants. Set friction, restitution, damping, mass/density, gravity scale, and collision groups explicitly.
- Balls, mini-golf, pool, pinball: rolling friction/damping, restitution, cushion bounce, hole capture, max velocity.
- Arcade vehicles: combine kinematic control logic with collision response rather than relying on raw rigid-body simulation.
- Character controllers: capsule colliders, locked rotations, kinematic movement — unless ragdoll is actually wanted.
## Failure modes
Variable-delta physics feels nondeterministic · mesh and body drift apart because transforms are synced in two places · bodies survive a restart · detailed imported meshes make collision slow or wrong · fast projectiles tunnel without CCD · sensors fire nothing because active events are missing · kinematic platforms move visually but not physically.
Report engine used, body/collider counts, timestep, CCD bodies, sensors, and collision groups when physics is in scope.
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:]))
SKILL.md
---
name: threejs-gameplay-systems
description: "Build and iterate playable Three.js game systems: starter scaffold, architecture, design briefs, core loops, level and encounter design, entities, input, camera, collision and physics, scoring, objectives, and game feel. Use for first playable slices, new Vite/TypeScript/Three.js setups, level/arena/track/wave/hole/puzzle design, combat encounters, difficulty tuning, and juice."
---
# Three.js Gameplay Systems
Create or evolve a playable browser game loop with clear ownership, responsive controls, deterministic update order, and a space that shapes player decisions.
Resolve `<this-skill-dir>` and local references from the actual loaded skill file; resolve sibling skills beside it before using runner-discovered alternatives.
## References
| File | Read it when |
| --- | --- |
| `references/game-feel.md` | tuning feel, juice, impact, hitstop, screenshake, or claiming polished gameplay |
| `references/genre-design.md` | designing levels, arenas, tracks, waves, holes, puzzles, encounters, or difficulty curves |
| `references/physics-engine-selection.md` | adding or changing physics, collision-heavy gameplay, vehicles, rolling balls, character controllers, sensors, moving platforms |
## Start here
```bash
python3 <this-skill-dir>/scripts/create_threejs_game.py ./my-game
```
Copies `assets/threejs-vite-game/`, rewrites the project name, and gives the game its own visual test and canvas inspector. `--force` copies into a non-empty directory, overwriting files that collide with the scaffold and leaving everything else in place.
## Design first
For broad game creation or a substantial design change, write three short artifacts or update the existing ones. A narrow mechanic fix uses the existing design and preserves unrelated systems:
**Design brief.** Player promise (the fantasy in one sentence) · target feeling · primary verb · secondary verbs · what the player repeats every 5–30 seconds · what changes across 1–5 minutes · how they lose, learn, and restart · what is rewarded and what creates risk · what a better player does differently · how the next decision is communicated · non-goals for this slice.
**Core loop contract.** `Player does [verb] to achieve [objective] while [pressure] creates risk; success gives [reward], failure causes [cost/retry].` Then prove each clause in code: the verb is mapped to real input, the objective is visible in world or HUD, pressure exists inside the first playable minute, reward changes state rather than only visuals, failure teaches what happened, and restart is fast enough to invite another attempt.
**Level or encounter plan.** Spatial format · what the camera can and cannot see · player start, first decision, first threat, first reward · landmarks · how challenge escalates every 20–60 seconds or per wave/hole/lap · recovery beats · how hazards are telegraphed · which pieces are modular or parameterized.
"Explore a cool scene" is not a design brief. A brief needs decisions, pressure, feedback, and consequence.
## Build
1. Inspect existing structure, scripts, dependencies, loop, input, camera, entities, state, UI, diagnostics.
2. Keep small ownership boundaries: `core`, `game`, `entities`, `systems`, `assets`, `ui`, `tests`.
3. Implement in playable increments — input, state, entity, collision, feedback, HUD and audio hooks, diagnostics — so something is playable at every step.
Establish one representative encounter at the intended camera scale with the actual hero and feedback before expanding content. Enemy, reward, and prop variety should serve the genre's decisions, not a universal asset quota.
4. Tune feel: movement, acceleration, camera follow and FOV, hitstop, impact feedback, cooldowns, difficulty, restart.
5. Keep hot paths allocation-light and update order explicit.
6. Route all gameplay randomness through the scaffold's seeded RNG so the deterministic test hooks keep working.
For coordinated work, define input intents, entity/asset interfaces, and events before delegating independent modules. Report changed behavior and local checks to the lead, who owns the consolidated browser pass. When a user changes requirements, update the existing brief and pending tasks instead of restarting completed work.
Gameplay code emits audio events; `threejs-audio-generator` produces the actual assets and the runtime audio matrix.
## Stack
TypeScript, Vite, Three.js modules, `three/addons/...` for official controls, loaders, and post-processing. `lil-gui` for live-tuned constants. Web Audio for runtime playback. Physics engine choice, timestep, and collider strategy follow `references/physics-engine-selection.md` — Rapier by default when the game needs real simulation.
## What goes wrong
A static demo instead of a playable loop · mechanics bolted onto a scene that was built first · a core loop described but never proven through real input, pressure, reward, and fail/retry · a track or arena that decorates rather than shapes decisions · a mechanic that compiles but no input can trigger · camera and controls that lag or hide the next decision · state changes that never reach UI, audio, or VFX · abstractions built before any mechanic needed them.
## Report
Behavior and controls, the three design artifacts for broad builds, architecture choices and tuned values, changed files, and what you saw when you played it. Note the physics engine, timestep, and collider strategy when physics is in scope.