| name | game-perf |
| description | 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.
const context = {
enemies: [...this.enemies],
projectiles: [...this.projectiles],
};
Fix: Pass readonly references.
const context = {
enemies: this.enemies as readonly EnemyState[],
projectiles: this.projectiles as readonly ProjectileState[],
};
2. Array.filter() in Hot Paths
Problem: filter() always creates a new array.
const activeEnemies = enemies.filter(e => e.active);
Fix: In-place filtering with swap-and-truncate.
function 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.
const positions = enemies.map(e => e.worldPos);
steering.separation(ctx, positions, radius);
Fix: Scratch array or inline iteration.
const positionsScratch: Vec2[] = [];
function getPositions(enemies: readonly EnemyState[]): readonly Vec2[] {
positionsScratch.length = 0;
for (const e of enemies) {
positionsScratch.push(e.worldPos);
}
return positionsScratch;
}
4. Filter + Map Chains
Problem: Double allocation.
const activePositions = enemies
.filter(e => e.active)
.map(e => e.worldPos);
Fix: Single-pass with scratch array.
const scratch: Vec2[] = [];
function getActivePositions(enemies: readonly EnemyState[]): readonly Vec2[] {
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.
function getWrappedPositions(pos: Vec2): Vec2[] {
const positions = [pos];
return positions;
}
Fix: Module-level scratch with readonly return.
const scratchPositions: Vec2[] = [];
function getWrappedPositions(pos: Vec2): readonly Vec2[] {
scratchPositions.length = 0;
scratchPositions.push(pos);
return scratchPositions;
}
The readonly return type signals to callers: "consume immediately, do not store."
6. O(n²) Proximity Queries
Problem: Checking every entity against every other entity.
for (const enemy of enemies) {
const nearby = enemies.filter(e =>
e !== enemy && distance(e.pos, enemy.pos) < radius
);
}
Fix: Spatial hash grid for O(n) build + O(1) queries.
const grid = new Map<string, Entity[]>();
const CELL_SIZE = 100;
function buildGrid(entities: readonly Entity[]): void {
grid.clear();
for (const e of entities) {
const key = `${Math.floor(e.pos.x / CELL_SIZE)},${Math.floor(e.pos.y / CELL_SIZE)}`;
if (!grid.has(key)) grid.set(key, []);
grid.get(key)!.push(e);
}
}
function queryNearby(pos: Vec2, radius: number): readonly Entity[] {
scratch.length = 0;
const cx = Math.floor(pos.x / CELL_SIZE);
const cy = Math.floor(pos.y / CELL_SIZE);
( dx = -; dx <= ; dx++) {
( dy = -; dy <= ; dy++) {
cell = grid.();
(cell) {
( e cell) {
((e., pos) < radius) scratch.(e);
}
}
}
}
scratch;
}
7. Object Creation in Loops
Problem: Creating temporary objects inside loops.
for (const enemy of enemies) {
const ctx = { position: enemy.pos, velocity: enemy.vel };
updateAI(ctx);
}
Fix: Reuse a single context object.
const ctx = { position: { x: 0, y: 0 }, velocity: { x: 0, y: 0 } };
for (const enemy of enemies) {
ctx.position.x = enemy.pos.x;
ctx.position.y = enemy.pos.y;
ctx.velocity.x = enemy.vel.x;
ctx.velocity.y = enemy.vel.y;
updateAI(ctx);
}
Architecture Patterns
Build Once, Query Many
buildSpatialGrid(entities);
buildEnemyGrid(enemies);
for (const entity of entities) {
const nearby = queryNearby(entity.pos, RADIUS);
}
Readonly Signals Transience
When a function returns a readonly array, it communicates:
- The array is a scratch buffer
- Caller must consume immediately
- Do not store the reference
- Contents will change on next call
Object Pooling for Frequent Create/Destroy
For entities created/destroyed frequently (particles, projectiles):
class Pool<T> {
private available: T[] = [];
acquire(factory: () => T): T {
return this.available.pop() ?? factory();
}
release(item: T): void {
this.available.push(item);
}
}
Deferred Destruction
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.
for (const e of world.asteroids) {
if (e.dead) world.asteroids.delete(e);
}
world.destroy(e);
world.flush();
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.
function inViewport(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:
| Performance Constraint | Design Implication |
|---|
| Entity count cap (e.g., 500 at 60fps) | Limits enemy density, particle counts, projectile counts — affects encounter design |
| Spatial hash cell size | 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:
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