YokoCoder-4B / HTML_CSS_JS_AND_THREEJS_GAMEDEV_GUIDE.md
ahpah's picture
Add HTML/CSS/JS & Three.js Video Game Engineering Guide
839dd8c verified
|
Raw History Blame Contribute Delete
15.7 kB
# The YokoCoder:4B Master Guide to HTML/CSS/JS (2D Canvas) & Three.js (3D WebGL) Video Game Engineering
This guide serves both as a **reference manual for game developers** and as a **core architectural specification distilled into the `YokoCoder:4B` dataset**. Every pattern below is engineered to eliminate the most common failure modes in browser-based 2D and 3D gamesβ€”such as `ReferenceError: dt is not defined`, stacked `requestAnimationFrame` loops, variable-framerate physics drift, hitbox mismatches, and Three.js memory leaks.
---
## 1. Single-File HTML/CSS/JS Game Architecture
A production-quality single-file HTML game must be 100% self-contained (zero external image or audio file dependencies that could fail to load via CORS) and strictly ordered into five deterministic layers inside `<script>`:
```text
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 1. CONFIGURATION & CONSTANTS (Immutable tuning parameters, colors) β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ 2. RUNTIME STATE & INPUT REGISTRY (Single source of truth, lexical) β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ 3. PROCEDURAL ASSETS & WEB AUDIO SYNTH (Lazy AudioContext, sprites) β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ 4. PURE UPDATE(dt) & COLLISION SOLVERS (Deterministic math, no DOM) β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ 5. RENDER(dt, alpha) & RAF SCHEDULER (Canvas/WebGL draw, single loop)β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
```
### 1.1 The Responsive Canvas Shell (Smooth Vector vs. Crisp Pixel Art)
Always prevent mobile pinch-zoom, double-tap selection, and Space/Arrow page scrolling:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>YokoCoder Arcade Engine</title>
<style>
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
user-select: none;
-webkit-user-select: none;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #0b0f19;
display: flex;
align-items: center;
justify-content: center;
font-family: system-ui, -apple-system, sans-serif;
touch-action: none;
}
canvas {
display: block;
touch-action: none;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.7);
/* Enable for low-res 8-bit pixel art games: */
/* image-rendering: pixelated; image-rendering: crisp-edges; */
}
</style>
</head>
<body>
<canvas id="game"></canvas>
<script>
// Game engine code goes here
</script>
</body>
</html>
```
### 1.2 HiDPI Scaling for Vector Games vs. Integer Scaling for Pixel Art
- **Smooth Vector Games (`400x600` logical space)**:
Scale the physical canvas backing store by `dpr = Math.min(window.devicePixelRatio || 1, 2)` and call `ctx.setTransform(dpr, 0, 0, dpr, 0, 0)` so all game logic uses fixed logical coordinates (`W = 400`, `H = 600`) regardless of screen resolution.
- **Native Pixel-Art Games (`144x256` or `160x288` logical buffer)**:
Keep `canvas.width = 144` and `canvas.height = 256`, set `ctx.imageSmoothingEnabled = false`, and scale the CSS width/height by an integer multiplier `scale = Math.max(1, Math.floor(Math.min(window.innerWidth / 144, window.innerHeight / 256)))`.
---
## 2. The Golden Game Loop & Why `ReferenceError: dt is not defined` Happens
### 2.1 Root Cause of `Uncaught ReferenceError: dt is not defined`
One of the most frequent bugs generated by small coding models is computing `const dt = ...` as a block-scoped local variable inside `loop(now)`, and then referencing `dt` inside `draw()` or `update()` **without passing `dt` as a function parameter**:
```javascript
// ❌ BROKEN: dt is block-scoped inside loop(), so draw() throws ReferenceError!
function draw() {
wingPhase += dt * 10; // πŸ’₯ Uncaught ReferenceError: dt is not defined
}
function loop(now) {
const dt = Math.min(0.1, (now - lastTime) / 1000);
lastTime = now;
update(dt);
draw(); // ❌ Forgot to pass dt!
requestAnimationFrame(loop);
}
```
### 2.2 The Invariant Fix: Explicit `update(dt)` and `draw(dt)` Signatures + Fixed-Step Accumulator
To guarantee that `dt` is **never** undefined and physics is **100% identical on 60Hz, 120Hz, 144Hz, and 240Hz monitors**:
1. Declare timing variables (`lastTime`, `accumulator`, `rafId`) in the module scope before `loop`.
2. Guard the first frame (`if (!lastTime) lastTime = now;`) so `now - lastTime` is never `NaN` or huge on startup.
3. Clamp `frameDt = Math.min(0.1, Math.max(0, (now - lastTime) / 1000))` so switching browser tabs never causes physics tunneling.
4. Step physics in fixed `FIXED_DT = 1 / 120` increments and pass `FIXED_DT` explicitly into `update(dt)` and `frameDt` explicitly into `draw(dt)`.
```javascript
const FIXED_DT = 1 / 120;
let lastTime = 0;
let accumulator = 0;
let rafId = null;
function update(dt = FIXED_DT) {
// Pure physics & collision using explicit parameter `dt`
}
function draw(dt = FIXED_DT, alpha = 1) {
// Visual animation timers (wing flap, particles, shaders) use explicit `dt`
}
function loop(now = performance.now()) {
rafId = requestAnimationFrame(loop);
if (lastTime === 0) lastTime = now;
const frameDt = Math.min(0.1, Math.max(0, (now - lastTime) / 1000));
lastTime = now;
accumulator += frameDt;
while (accumulator >= FIXED_DT) {
update(FIXED_DT);
accumulator -= FIXED_DT;
}
draw(frameDt, accumulator / FIXED_DT);
}
// Start the single persistent RAF loop once:
rafId = requestAnimationFrame(loop);
```
---
## 3. State Machine & Input Discipline
### 3.1 Never Spawn Duplicate `requestAnimationFrame` Loops on Restart
A classic bug occurs when `startGame()` or `resetGame()` calls `loop()` while a previous `requestAnimationFrame(loop)` is still runningβ€”doubling or tripling game speed after every death!
**Rule**: Either keep **one persistent `requestAnimationFrame` loop** running across all states (`READY`, `PLAYING`, `GAMEOVER`) and branch on `state` inside `update(dt)`, OR always call `if (rafId !== null) cancelAnimationFrame(rafId);` before scheduling a new frame.
### 3.2 Time-Based Game-Over Grace Period (Never Use `frames % 10 === 0`!)
Checking `if (frames % 10 === 0)` inside a click/keydown handler to "debounce" restarts drops **90% of user clicks** because input events fire asynchronously relative to frame ticks! Instead, track a time-based cooldown `gameOverLockTimer`:
```javascript
const STATE = { READY: 'READY', PLAYING: 'PLAYING', GAMEOVER: 'GAMEOVER' };
let state = STATE.READY;
let gameOverLockTimer = 0; // Seconds remaining before restart input is accepted
function enterGameOver() {
if (state === STATE.GAMEOVER) return;
state = STATE.GAMEOVER;
gameOverLockTimer = 0.35; // 350ms grace period prevents accidental instant skip
bestScore = Math.max(bestScore, score);
saveHighScore(bestScore);
}
function handleAction() {
if (state === STATE.READY) {
resetMatch();
state = STATE.PLAYING;
bird.flap();
} else if (state === STATE.PLAYING) {
bird.flap();
} else if (state === STATE.GAMEOVER && gameOverLockTimer <= 0) {
resetMatch();
state = STATE.PLAYING;
bird.flap();
}
}
```
---
## 4. 2D Collision Detection Mathematics
### 4.1 AABB vs. AABB (Axis-Aligned Bounding Box)
Two rectangles $A = (x_A, y_A, w_A, h_A)$ and $B = (x_B, y_B, w_B, h_B)$ intersect if and only if projections on both axes overlap:
```javascript
function aabbOverlap(ax, ay, aw, ah, bx, by, bw, bh) {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
```
### 4.2 Forgiving Circle-vs-AABB Collision (Ideal for Rotating Sprites & Birds)
When a sprite rotates visually (like the Flappy Bird tilting between $-25^\circ$ and $+90^\circ$), an axis-aligned rectangle has sharp invisible corners that feel unfair to players. A **circle** of center $(c_x, c_y)$ and radius $r$ is invariant under rotation. Clamp $(c_x, c_y)$ to the rectangle $[x, x+w] \times [y, y+h]$ to find the closest point $(p_x, p_y)$:
```javascript
function circleIntersectsAABB(cx, cy, r, rx, ry, rw, rh) {
const closestX = Math.max(rx, Math.min(cx, rx + rw));
const closestY = Math.max(ry, Math.min(cy, ry + rh));
const dx = cx - closestX;
const dy = cy - closestY;
return (dx * dx + dy * dy) < (r * r);
}
```
### 4.3 Preventing the "Fly Over Top Pipe" Bypass Bug
In scrolling obstacle games, if the player flaps near $y = 0$ and the pipe collision check only tests `bird.y < pipe.top` while `bird.y` is allowed to go negative (or if top pipe caps start at $y = 0$), always:
1. Clamp the player ceiling: `if (bird.y - bird.r < 0) { bird.y = bird.r; bird.vy = 0; }`, **and**
2. Treat any horizontal overlap with a pipe while `bird.y - bird.r < pipe.gapTop` as a collision, even if `bird.y < 0`.
---
## 5. Three.js 3D Game Engineering Guide
### 5.1 Modern Import Map & Scene Setup
Use ES Module Import Maps or explicit CDN script tags with a locked Three.js version (`0.160.0`) so `THREE` is reliably available without a bundler:
```html
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
</script>
```
### 5.2 Preventing the Three.js "Black Screen" Bug
Four mistakes cause a blank or black Three.js canvas:
1. **Using `MeshStandardMaterial` or `MeshPhysicalMaterial` without lights**: PBR materials require at least an ambient/hemisphere light (`THREE.HemisphereLight`) and a directional/point light (`THREE.DirectionalLight`). Only `MeshBasicMaterial` renders without lights.
2. **Camera inside the geometry or looking the wrong way**: Always set `camera.position.set(x, y, z)` and `camera.lookAt(targetX, targetY, targetZ)` explicitly.
3. **Zero-height container**: Ensure `html, body { width: 100%; height: 100%; margin: 0; }` and size the renderer via `renderer.setSize(window.innerWidth, window.innerHeight)`.
4. **Missing `camera.updateProjectionMatrix()` on resize**:
```javascript
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
```
### 5.3 Hierarchical Joint Rigging (`THREE.Group` Pivot Pattern)
By default, Three.js primitives (`BoxGeometry`, `CylinderGeometry`) rotate around their geometric center $(0, 0, 0)$. To animate limbs, wings, or doors around a **joint hinge** (shoulder, hip, wing root):
1. Create a `const joint = new THREE.Group()` positioned at the hinge coordinates on the parent body.
2. Create the limb mesh and offset its local position by half its length (`limbMesh.position.y = -limbLength / 2`).
3. Rotate `joint.rotation.x`β€”never the offset child mesh directly!
### 5.4 Preventing WebGL GPU Memory Leaks (`dispose()` Lifecycle)
Unlike JavaScript objects, Three.js `BufferGeometry`, `Material`, and `Texture` instances allocate GPU buffers in WebGL that are **not** freed by the browser's garbage collector when removed from a `Scene`.
- **Rule 1**: Pre-allocate shared geometries and materials once at startup and reuse them across spawned obstacles, bullets, and particles (`const sharedPipeGeo = new THREE.CylinderGeometry(...)`).
- **Rule 2**: For high-count particles or voxel chunks, use `THREE.InstancedMesh` or merged face buffers.
- **Rule 3**: Whenever a unique mesh is permanently destroyed, dispose its GPU resources if not shared:
```javascript
function removeAndDispose(scene, mesh) {
scene.remove(mesh);
if (mesh.geometry && !mesh.userData.sharedGeo) mesh.geometry.dispose();
if (mesh.material && !mesh.userData.sharedMat) {
if (Array.isArray(mesh.material)) mesh.material.forEach(m => m.dispose());
else mesh.material.dispose();
}
}
```
---
## 6. Zero-Asset Procedural Web Audio Synthesizer
Browsers block audio until the first user gesture (`keydown` or `pointerdown`). Use a lazy-initialized `AudioContext` helper to synthesize crisp retro arcade sound effects in pure code:
```javascript
const SFX = (() => {
let ctx = null;
function unlock() {
if (!ctx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (AC) ctx = new AC();
}
if (ctx && ctx.state === 'suspended') ctx.resume();
return ctx;
}
function tone(type, fStart, fEnd, duration, gainVal = 0.12) {
const c = unlock();
if (!c) return;
const now = c.currentTime;
const osc = c.createOscillator();
const gain = c.createGain();
osc.type = type;
osc.frequency.setValueAtTime(fStart, now);
osc.frequency.exponentialRampToValueAtTime(Math.max(20, fEnd), now + duration);
gain.gain.setValueAtTime(gainVal, now);
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
osc.connect(gain).connect(c.destination);
osc.start(now);
osc.stop(now + duration);
}
return {
flap: () => tone('sine', 320, 640, 0.09, 0.12),
score: () => { tone('triangle', 587.33, 587.33, 0.07, 0.12); setTimeout(() => tone('triangle', 880, 880, 0.14, 0.12), 65); },
hit: () => tone('sawtooth', 160, 38, 0.26, 0.18),
};
})();
```
---
## 7. Pre-Flight Verification Checklist for Every Game
Before emitting the final `</html>` tag, verify all 8 invariants:
1. **Scope Audit**: Every parameter used in `update(dt)` and `draw(dt)` is either declared in its signature or in module-level state.
2. **First-Frame Timestamp Guard**: `if (!lastTime) lastTime = now;` prevents `NaN` or huge initial `dt`.
3. **Tab-Switch Clamp**: `frameDt = Math.min(0.1, (now - lastTime) / 1000)` prevents teleporting through walls.
4. **Single RAF Loop**: Restarting a match resets state variables without spawning a second concurrent `requestAnimationFrame(loop)`.
5. **Input Responsiveness**: Restart works reliably after a short time-based grace period (`0.35s`), never gated on `frames % N === 0`.
6. **Zero-Allocation Hot Loop**: Particle and projectile pools use swap-and-pop (`pool[i] = pool[pool.length - 1]; pool.pop()`) instead of `Array.shift()` or `Array.filter()` inside 60fps loops.
7. **Safe Storage**: All `localStorage.getItem` / `setItem` calls are wrapped in `try { ... } catch {}` so sandboxed iframes and `file://` origins never throw `SecurityError`.
8. **Three.js Resource Hygiene**: Geometries and materials are shared across obstacle instances, lights are present for PBR materials, and window resize updates both `camera.aspect` and `camera.updateProjectionMatrix()`.