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>:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 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:

<!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:

// ❌ 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).
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:

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:

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)$:

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:

<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:
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:
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:

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().