plugins/game-dev/skills/game-perf/SKILL.md
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.
npx skillsauth add rbergman/dark-matter-marketplace game-perfInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.
Trigger this skill when editing:
Problem: Spread creates a new array every call.
// BAD: Creates new array every frame
const context = {
enemies: [...this.enemies],
projectiles: [...this.projectiles],
};
Fix: Pass readonly references.
// GOOD: Zero allocation
const context = {
enemies: this.enemies as readonly EnemyState[],
projectiles: this.projectiles as readonly ProjectileState[],
};
Problem: filter() always creates a new array.
// BAD: New array every call
const activeEnemies = enemies.filter(e => e.active);
Fix: In-place filtering with swap-and-truncate.
// GOOD: Mutate in place
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;
}
Problem: map() creates a new array.
// BAD: New array every frame
const positions = enemies.map(e => e.worldPos);
steering.separation(ctx, positions, radius);
Fix: Scratch array or inline iteration.
// GOOD: Reuse scratch array
const positionsScratch: Vec2[] = [];
function getPositions(enemies: readonly EnemyState[]): readonly Vec2[] {
positionsScratch.length = 0;
for (const e of enemies) {
positionsScratch.push(e.worldPos);
}
return positionsScratch;
}
Problem: Double allocation.
// BAD: Two new arrays
const activePositions = enemies
.filter(e => e.active)
.map(e => e.worldPos);
Fix: Single-pass with scratch array.
// GOOD: Single pass, zero allocation
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;
}
Problem: Helper functions that return new arrays per call.
// BAD: New array per entity per frame
function getWrappedPositions(pos: Vec2): Vec2[] {
const positions = [pos];
// ... add wrapped positions
return positions;
}
Fix: Module-level scratch with readonly return.
// GOOD: Reusable scratch buffer
const scratchPositions: Vec2[] = [];
function getWrappedPositions(pos: Vec2): readonly Vec2[] {
scratchPositions.length = 0;
scratchPositions.push(pos);
// ... add wrapped positions
return scratchPositions;
}
The readonly return type signals to callers: "consume immediately, do not store."
Problem: Checking every entity against every other entity.
// BAD: O(n²) - checks all enemies for each enemy
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.
// GOOD: Build grid once, query many times
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);
// Check 3x3 cells
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
const cell = grid.get(`${cx + dx},${cy + dy}`);
if (cell) {
for (const e of cell) {
if (distance(e.pos, pos) < radius) scratch.push(e);
}
}
}
}
return scratch;
}
Problem: Creating temporary objects inside loops.
// BAD: New object per iteration
for (const enemy of enemies) {
const ctx = { position: enemy.pos, velocity: enemy.vel };
updateAI(ctx);
}
Fix: Reuse a single context object.
// GOOD: Reuse 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);
}
// Per-frame setup phase
buildSpatialGrid(entities);
buildEnemyGrid(enemies);
// Per-entity query phase (many times)
for (const entity of entities) {
const nearby = queryNearby(entity.pos, RADIUS);
// process nearby...
}
When a function returns a readonly array, it communicates:
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);
}
}
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.
Before committing changes to per-frame code:
[...array]) on arrays that don't changefilter() / map() / reduce() creating new arrays{}) or array literals ([]) inside loopsreadonly for scratch buffersdevelopment
Initialize a new repository with standard scaffolding - git, gitignore, AGENTS.md, justfile, mise, beads, and timbers. Use when starting a new project or setting up an existing repo for Claude Code workflows.
data-ai
Activate at session start when using Agent Teams for complex multi-agent work. Establishes team lead role with delegation protocols, teammate spawning, model selection, and beads integration. You coordinate the team; teammates implement.
data-ai
Use when creating a worktree, setting up a worktree, starting feature work that needs isolation, or before executing implementation plans. Covers git worktree creation under .worktrees/, gitignore setup, beads integration, and merge guardrails.
data-ai
Activate when you are a delegated subagent (not the orchestrator). Establishes subagent protocol with terse returns, details to history/, file ownership boundaries, and escalation rules. You implement; orchestrator reviews and commits.