| name | game-optimization |
| description | Make a browser game hold a rock-solid 60fps (or 120+) under real load, in 2D or 3D. Use whenever a game stutters, slows as entities spawn, runs at different speeds on different machines, or before claiming a game is "done." Covers the fixed-timestep game loop (framerate-independent, deterministic physics), object pooling (zero-GC steady state), sprite batching / texture atlases (fewer draw calls), spatial partitioning, and profiling. Ships a drop-in fixed-timestep loop and a generic object pool. |
Game Optimization — 60fps is a feature, not an accident
A browser game that hits 60fps empty and drops to 20 when the action starts isn't optimized, it's lucky. And a game where movement is faster on a 144Hz monitor than a 60Hz one isn't just slow — it's wrong. Performance and correctness are the same problem here: both come from decoupling the simulation from the frame.
BANNED (the default AI-game performance failures)
x += speed per frame. Framerate-dependent: double-speed at 120Hz, half under load, non-reproducible bugs. Everything moves by dt or on a fixed timestep.
- Physics tied to render rate. Simulating "once per drawn frame" makes physics jitter and behave differently on every machine. Fixed timestep, always.
- Allocating in the loop.
new, [], {}, .map/.filter/.forEach closures, template strings for UI — every frame. Feeds the GC until it stalls mid-fight. Pool and reuse.
- One draw call per sprite. Switching texture/state per object murders throughput. Batch via a texture atlas.
- O(n²) everything. Collision, AI target-finding, nearest-neighbour by scanning all entities. Spatial-partition it.
- Rendering at full DPR on 4K/mobile. 3–4× the fill cost for no visible gain, then thermal throttling. Cap DPR.
- "Runs fine for me." Never profiled, never stress-tested, never run at 60Hz or on a phone.
The two root causes of almost every game perf problem
- The loop is wrong → speed varies, physics jitters, one big frame teleports things. Fix: fixed-timestep accumulator + interpolated render. See references/game-loop.md.
- The loop allocates → periodic GC hitches. Fix: object pools, zero steady-state garbage. See references/pooling-and-gc.md.
Fix these two and most "it stutters" complaints vanish. Everything else (batching, partitioning) is for scaling entity/draw counts higher.
Process
- Get the loop right first — fixed timestep for simulation, delta/interpolation for render. tools/game-loop.js, references/game-loop.md. This alone fixes speed-varies-by-monitor and most physics jitter. Non-negotiable foundation.
- Pool everything that spawns — bullets, enemies, particles, damage numbers, vectors. tools/object-pool.js, references/pooling-and-gc.md. Target: heap flat during play.
- Cut draw calls — texture atlas + sprite batching; tilemap layers batch to 1–2 calls; instance repeated 3D meshes. references/rendering-perf.md.
- Spatial-partition the O(n²) — grid/quadtree for collision and neighbour queries (shared with game-collision's broad-phase). references/rendering-perf.md.
- Profile, don't guess — Chrome DevTools Performance, an fps/frame-time meter, draw-call count. Find the actual bottleneck before optimizing. references/profiling.md.
- Stress + reality test — 200+ entities, 60Hz and 144Hz, throttled tab, mid-tier phone. If it only holds 60fps empty on your desktop, it's not done.
The 16ms budget (60fps)
Each frame has ~16.6ms for everything — input, simulation, render, GC. Realistically keep game logic under ~8–10ms to leave room for the browser. When you blow the budget: profile → find the spike → is it allocation (pool it), draw calls (batch), or an O(n²) scan (partition)? Optimize that, re-measure. Never optimize by vibes.
Companion
Pairs with browser-engine (the constraints and loop context) and game-collision (its broad-phase is the same spatial partition you use here). Optimization and correct collision share the same structures — build them together.
The 60fps checklist (before claiming done)