| name | game-design-patterns |
| version | 0.1.0 |
| description | Use this skill when implementing game programming patterns - state machines for character/AI behavior, object pooling for performance-critical spawning, event systems for decoupled game communication, or the command pattern for input handling, undo/redo, and replays. Triggers on game architecture, game loop design, entity management, finite state machines, object pools, observer/event bus, command queues, and gameplay programming patterns.
|
| tags | ["game-dev","design-patterns","state-machine","object-pool","event-system","command-pattern","experimental-design","performance"] |
| category | engineering |
| recommended_skills | ["unity-development","game-balancing","clean-architecture"] |
| platforms | ["claude-code","gemini-cli","openai-codex","mcp"] |
| license | MIT |
| maintainers | [{"github":"maddhruv"}] |
Key principles
-
Frame budget is law - Every pattern choice must respect the ~16ms frame budget.
Allocations during gameplay cause GC spikes. Indirection has cache costs. Always
profile before adding abstraction.
-
Decouple, but not infinitely - Game systems should communicate through events
and commands rather than direct references, but over-decoupling creates debugging
nightmares. One level of indirection is usually enough.
-
State is explicit - Implicit state (nested boolean flags, mode integers) leads
to impossible combinations and subtle bugs. Make every valid state a first-class
object with defined transitions.
-
Pool what you spawn - Any entity created and destroyed more than once per
second should be pooled. The cost of allocation is not the constructor - it is the
garbage collector pause 3 seconds later.
-
Commands are data - When input actions are objects rather than direct method
calls, you get undo, replay, networking, and AI "for free." The command pattern
is the single highest-leverage pattern in gameplay code.
Core concepts
State machines model entities that have distinct behavioral modes. A character
can be Idle, Running, Jumping, or Attacking - but never Jumping and Idle at the same
time. Each state encapsulates its own update logic, entry/exit behavior, and valid
transitions. Hierarchical state machines (HFSM) add nested sub-states for complex AI.
Object pooling pre-allocates a fixed set of objects and recycles them instead of
creating and destroying instances at runtime. The pool maintains an "available" list
and hands out pre-initialized objects on request, reclaiming them when they are
"killed." This eliminates allocation pressure during gameplay.
Event systems (also called observer, pub/sub, or message bus) let game systems
communicate without direct references. When a player takes damage, the health system
fires a DamageTaken event. The UI, audio, camera shake, and analytics systems each
subscribe independently. Adding a new reaction requires zero changes to the damage code.
The command pattern encapsulates an action as an object with execute() and
optionally undo(). Player input becomes a stream of command objects. This enables
input rebinding, replay recording, undo/redo in editors, and sending commands over
the network for multiplayer.
Common tasks
Implement a finite state machine for character behavior
Each state is a class with enter(), update(), exit(), and a transition check.
The machine holds the current state and delegates to it.
interface State {
enter(): void;
update(dt: number): void;
exit(): void;
}
class IdleState implements State {
constructor(private character: Character) {}
enter() { this.character.playAnimation("idle"); }
update(dt: number) {
if (this.character.input.jump) {
this.character.fsm.transition(new JumpState(this.character));
}
}
exit() {}
}
class StateMachine {
private current: State;
transition(next: State) {
this.current.exit();
this.current = next;
this.current.enter();
}
update(dt: number) {
this.current.update(dt);
}
}
Avoid string-based state names. Use typed state classes so the compiler catches
invalid transitions.
Build an object pool
Pre-allocate objects at startup. acquire() returns a recycled instance; release()
returns it to the pool. Never allocate during gameplay.
class ObjectPool<T> {
private available: T[] = [];
private active: Set<T> = new Set();
constructor(
private factory: () => T,
private reset: (obj: T) => void,
initialSize: number
) {
for (let i = 0; i < initialSize; i++) {
this.available.push(this.factory());
}
}
acquire(): T | null {
if (this.available.length === 0) return null;
const obj = this.available.pop()!;
this.active.add(obj);
return obj;
}
release(obj: T): void {
if (!this.active.has(obj)) return;
this.active.delete(obj);
this.reset(obj);
this.available.push(obj);
}
}
const bulletPool = new ObjectPool(
() => new Bullet(),
(b) => { b.active = false; b.position.set(0, 0); },
200
);
Size the pool to your worst-case burst. If acquire() returns null, either grow
the pool (with a warning log) or skip the spawn - never allocate inline.
Set up a typed event system
Use a type-safe event bus so subscribers know exactly what payload to expect.
type EventMap = {
"damage-taken": { target: Entity; amount: number; source: Entity };
"enemy-killed": { enemy: Entity; killer: Entity; score: number };
"level-complete": { level: number; time: number };
};
class EventBus {
private listeners = new Map<string, Set<Function>>();
on<K extends keyof EventMap>(event: K, handler: (data: EventMap[K]) => void) {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event)!.add(handler);
return () => this.listeners.get(event)!.delete(handler);
}
emit<K extends keyof EventMap>(event: K, data: EventMap[K]) {
this.listeners.get(event)?.forEach(fn => fn(data));
}
}
const bus = new EventBus();
const unsub = bus.on("damage-taken", ({ target, amount }) => {
healthBar.update(target.id, amount);
});
Always return an unsubscribe function. Leaked subscriptions from destroyed entities
are the #1 event system bug in games.
Implement the command pattern for input with undo
Each player action is a command object. Store a history stack for undo.
interface Command {
execute(): void;
undo(): void;
}
class MoveCommand implements Command {
private previousPosition: Vector2;
constructor(private entity: Entity, private direction: Vector2) {}
execute() {
this.previousPosition = this.entity.position.clone();
this.entity.position.add(this.direction);
}
undo() {
this.entity.position.copy(this.previousPosition);
}
}
class CommandHistory {
private history: Command[] = [];
private pointer = -1;
execute(cmd: Command) {
this.history.length = this.pointer + 1;
cmd.execute();
this.history.push(cmd);
this.pointer++;
}
undo() {
if (this.pointer < 0) return;
this.history[this.pointer].undo();
this.pointer--;
}
redo() {
if (this.pointer >= this.history.length - 1) return;
this.pointer++;
this.history[this.pointer].execute();
}
}
For replay systems, serialize commands with timestamps. Replay = feed the same
command stream to a fresh game state.
Use a hierarchical state machine for complex AI
When a single FSM has too many states, use sub-states. A "Combat" state can contain
"Attacking", "Flanking", and "Retreating" sub-states.
class HierarchicalState implements State {
protected subMachine: StateMachine;
enter() { this.subMachine.transition(this.getInitialSubState()); }
update(dt: number) { this.subMachine.update(dt); }
exit() { this.subMachine.currentState?.exit(); }
protected getInitialSubState(): State {
throw new Error("Override in subclass");
}
}
class CombatState extends HierarchicalState {
constructor(private ai: AIController) {
super();
this.subMachine = new StateMachine();
}
protected getInitialSubState(): State {
return new AttackingSubState(this.ai);
}
}
Limit nesting to 2 levels. Three or more levels of hierarchy signals you need
a behavior tree instead.
Implement command pattern for multiplayer input
Send commands over the network instead of state. Both clients execute the same
command stream deterministically.
interface NetworkCommand extends Command {
serialize(): ArrayBuffer;
readonly playerId: string;
readonly frame: number;
}
class NetworkCommandBuffer {
private buffer: Map<number, NetworkCommand[]> = new Map();
addCommand(frame: number, cmd: NetworkCommand) {
if (!this.buffer.has(frame)) this.buffer.set(frame, []);
this.buffer.get(frame)!.push(cmd);
}
getCommandsForFrame(frame: number): NetworkCommand[] {
return this.buffer.get(frame) ?? [];
}
}
Deterministic lockstep requires all clients to process the exact same commands
in the exact same frame order. Floating-point differences across platforms
will cause desync - use fixed-point math for critical state.
Anti-patterns / common mistakes
| Mistake | Why it's wrong | What to do instead |
|---|
| Boolean state flags | isJumping && !isAttacking && isDashing creates impossible-to-debug combinations | Use an explicit state machine with typed states |
| Allocating in the hot loop | new Bullet() every frame causes GC pauses and frame drops | Pool all frequently spawned objects |
| God event bus | Every system subscribes to everything on one global bus | Scope buses per domain (combat bus, UI bus) or use direct listeners for tight couplings |
| Commands without undo | Implementing execute() but skipping undo() for "simplicity" | Always implement undo() even if unused now - replay and debugging need it |
| Stringly-typed events | Using raw strings like "dmg" instead of typed event names | Use a typed EventMap (TypeScript) or enum-based keys so typos are compile errors |
| Unbounded command history | Storing every command forever leaks memory in long sessions | Cap history length or checkpoint + truncate periodically |
| Spaghetti transitions | Every state can transition to every other state | Define a transition table upfront. If a transition is not in the table, it is illegal |
Gotchas
-
Object pools sized for average load, not burst load, cause missed spawns - If you size a bullet pool for "average 50 bullets" but the boss fight fires 200 in 2 seconds, acquire() returns null and bullets silently fail to spawn. Always size pools to the worst-case burst in your game, add pool expansion with a warning log, and test the burst scenario explicitly.
-
State machine transitions that allocate new State objects cause GC pressure - If each transition() call does new JumpState(character), you're allocating during gameplay, which triggers garbage collection pauses. Pre-allocate all state instances at startup and store them in a dictionary; transition by swapping references, not by creating new objects.
-
Event bus subscriptions from destroyed entities cause null reference crashes - When a game object is destroyed without unsubscribing its event handlers, the next event dispatch calls a handler with a null this context and crashes or produces stale state. Always store and invoke the unsubscribe function returned by on() in the entity's destroy/cleanup path.
-
Command history grows unbounded in long sessions - Storing every command since session start for an undo system will consume growing memory over hours of gameplay. Cap the command history to a maximum depth (e.g., 100 commands) or checkpoint-and-truncate periodically. For replay systems, commands older than the checkpoint can be dropped.
-
Deterministic lockstep breaks silently on floating-point operations - Two clients running the same command stream will desync if any physics or movement calculation uses floating-point math, because IEEE 754 results can differ across CPU architectures and compiler optimizations. Use fixed-point arithmetic for all game state that must be deterministic across clients.
References
For detailed content on specific patterns, read the relevant file from references/:
references/state-machines.md - Hierarchical FSMs, pushdown automata, behavior tree comparison, and transition table design
references/object-pooling.md - Pool sizing strategies, warm-up patterns, thread safety, and language-specific GC considerations
references/event-systems.md - Event queue vs immediate dispatch, priority ordering, event filtering, and debugging leaked subscriptions
references/command-pattern.md - Serialization for replay/networking, macro recording, composite commands, and undo stack management
Only load a references file if the current task requires deep detail on that topic.
Companion check
On first activation of this skill in a conversation: check which companion skills are installed by running ls ~/.claude/skills/ ~/.agent/skills/ ~/.agents/skills/ .claude/skills/ .agent/skills/ .agents/skills/ 2>/dev/null. Compare the results against the recommended_skills field in this file's frontmatter. For any that are missing, mention them once and offer to install:
npx skills add AbsolutelySkilled/AbsolutelySkilled --skill <name>
Skip entirely if recommended_skills is empty or all companions are already installed.