Per-frame performance and GC-pressure optimization for JS/TS game code. Use when editing game loops, update functions, render passes, physics steps, particle systems, or any code that runs every frame; when diagnosing jank, frame drops, or stuttering; when allocations show up in flame graphs; or when the user mentions frame budget, hot paths, or 'feels janky'. Identifies common allocation anti-patterns (spread in loops, .map/.filter chains in update, closures captured per frame) and provides pooled / pre-allocated alternatives.
Per-frame performance and GC-pressure optimization for JS/TS game code. Use when editing game loops, update functions, render passes, physics steps, particle systems, or any code that runs every frame; when diagnosing jank, frame drops, or stuttering; when allocations show up in flame graphs; or when the user mentions frame budget, hot paths, or 'feels janky'. Identifies common allocation anti-patterns (spread in loops, .map/.filter chains in update, closures captured per frame) and provides pooled / pre-allocated alternatives.
Game Performance Optimization
Sources: These are standard JavaScript engine behaviours (generational GC,
hidden-class stability) rather than game-specific findings — see V8's public
documentation on allocation and garbage collection. Spatial hashing is textbook
broad-phase collision detection. No figures here are benchmarks; measure your own
hot path with a profiler before optimising it, per the Numbers Policy in
game-design.
This skill provides patterns for writing allocation-free, GC-friendly code in game loops and hot paths. Apply these patterns proactively when working on any code that executes per-frame.
Anti-Patterns and Fixes
1. Spread Operator Copies
Problem: Spread creates a new array every call.
// BAD: Creates new array every frameconst context = {
enemies: [...this.enemies],
projectiles: [...this.projectiles],
};
// BAD: New array every callconst activeEnemies = enemies.filter(e => e.active);
Fix: In-place filtering with swap-and-truncate.
// GOOD: Mutate in placefunction filterInPlace<T>(array: T[], predicate: (item: T) =>boolean): void {
let writeIndex = 0;
for (let i = 0; i < array.length; i++) {
if (predicate(array[i])) {
array[writeIndex++] = array[i];
}
}
array.length = writeIndex;
}
3. Array.map() for Transformations
Problem:map() creates a new array.
// BAD: New array every frameconst positions = enemies.map(e => e.worldPos);
steering.separation(ctx, positions, radius);
Fix: Scratch array or inline iteration.
// GOOD: Reuse scratch arrayconstpositionsScratch: Vec2[] = [];
functiongetPositions(enemies: readonlyEnemyState[]): readonlyVec2[] {
positionsScratch.length = 0;
for (const e of enemies) {
positionsScratch.push(e.worldPos);
}
return positionsScratch;
}
4. Filter + Map Chains
Problem: Double allocation.
// BAD: Two new arraysconst activePositions = enemies
.filter(e => e.active)
.map(e => e.worldPos);
Fix: Single-pass with scratch array.
// GOOD: Single pass, zero allocationconstscratch: Vec2[] = [];
functiongetActivePositions(enemies: readonlyEnemyState[]): readonlyVec2[] {
scratch.length = 0;
for (const e of enemies) {
if (e.active) scratch.push(e.worldPos);
}
return scratch;
}
5. Returning New Arrays from Utilities
Problem: Helper functions that return new arrays per call.
// BAD: New array per entity per framefunctiongetWrappedPositions(pos: Vec2): Vec2[] {
const positions = [pos];
// ... add wrapped positionsreturn positions;
}
Removing from a collection while iterating it is the most common source of
skipped-entity bugs, and the naive fix — copying the collection every frame — is
an allocation in the hot path. Mark, then sweep once.
// BAD - mutates during iteration; silently skips the following elementfor (const e of world.asteroids) {
if (e.dead) world.asteroids.delete(e);
}
// GOOD - mark during systems, sweep once after all of them have run
world.destroy(e); // sets a flag, pushes to a reused pending array// ...all systems run...
world.flush(); // single pass; clears pending without reallocating
Viewport Culling
When the world is larger than the screen, per-entity render work for off-screen
entities is pure waste. Set visible = false rather than skipping the update —
most renderers then discard the object before it reaches the GPU.
functioninViewport(x: number, y: number, r: number, vp: Rectangle): boolean {
return x + r > vp.x && x - r < vp.x + vp.width &&
y + r > vp.y && y - r < vp.y + vp.height;
}
Include the entity radius in the bounds test, or sprites pop at the edge.
For wraparound worlds, test every wrapped position, not just the canonical one.
Cull rendering, not simulation. An entity culled out of the sim behaves
differently depending on where the camera is, which reads as a bug and is
nearly impossible to reproduce deliberately.
Performance as Design Constraint
Performance isn't just an engineering concern — it constrains design decisions. Feed these constraints back into design early:
Determines minimum meaningful distance between entities — affects spacing design
Collision check budget
Limits simultaneous interacting entities — affects group combat design
Draw call budget
Limits visual complexity per frame — affects VFX and juice design
Memory budget
Limits world size and asset variety — affects content scope
Design rule: Establish performance budgets BEFORE designing encounters, particle effects, or entity populations. A design that requires 2000 entities at 60fps on a budget that supports 500 is not a performance problem — it's a design problem. See encounter-design and systems-design for design-level responses to performance constraints.
Checklist for Hot Path Code
Before committing changes to per-frame code:
No spread operators ([...array]) on arrays that don't change
No filter() / map() / reduce() creating new arrays
No object literals ({}) or array literals ([]) inside loops
Proximity queries use spatial partitioning if > 50 entities
Scratch arrays used for temporary results
Return types are readonly for scratch buffers
Context objects are reused, not recreated
Cross-References
Three tight links. Everything else routes through the map, so adding a skill
touches one file rather than twenty: references/routing-map.md (in
game-design).
game-feel — Frame budget is a design constraint, not just engineering
simulation-first-design — Headless sims trade rendering for runs per second