Entity-Component
System Architecture
The Entity-Component-System (ECS) pattern is arguably the most impactful architectural decision you can make in a JavaScript game. It inverts the classical OOP model: instead of objects carrying behaviour and data together, everything is decomposed into three orthogonal concepts.
Entity
A bare numeric ID — nothing more. No methods, no data. Think of it as a key in a database. Thousands can exist with near-zero memory cost.
Component
Pure, flat data structs attached to entities. Position, Velocity, Sprite, Health. No logic whatsoever — just plain JavaScript objects.
System
Functions that iterate over entities matching a component signature and transform their data. All game logic lives here, nowhere else.
The real power emerges from query-based composition. A MovementSystem operates on every entity that has both Position and Velocity. Freeze an entity? Add a Frozen tag component. The system skips it automatically. No inheritance tree to untangle.
// ── Minimal ECS World ─────────────────────────────── class World { constructor() { this.nextId = 0; this.components = new Map(); // Map<type, Map<entityId, data>> this.systems = []; } createEntity() { return this.nextId++; } addComponent(entity, type, data) { if (!this.components.has(type)) this.components.set(type, new Map()); this.components.get(type).set(entity, data); } query(...types) { // Return entity IDs that own ALL requested component types const [first, ...rest] = types; const candidates = [...(this.components.get(first)?.keys() ?? [])]; return candidates.filter(id => rest.every(t => this.components.get(t)?.has(id)) ); } get(entity, type) { return this.components.get(type)?.get(entity); } tick(dt) { for (const system of this.systems) system(dt, this); } } // ── Systems are plain functions ────────────────────── const MovementSystem = (dt, world) => { for (const id of world.query('Position', 'Velocity')) { const pos = world.get(id, 'Position'); const vel = world.get(id, 'Velocity'); pos.x += vel.x * dt; pos.y += vel.y * dt; } }; // ── Scene setup ───────────────────────────────────── const world = new World(); world.systems.push(MovementSystem); const player = world.createEntity(); world.addComponent(player, 'Position', { x: 0, y: 0 }); world.addComponent(player, 'Velocity', { x: 120, y: 0 }); world.addComponent(player, 'Sprite', { sheet: 'player.png' });
For cache-coherent access in hot loops, store components in typed Float32Arrays indexed by entity ID instead of nested Maps. Libraries like bitecs do exactly this, often hitting 10× throughput improvements on large entity counts.
| Scenario | ECS | Deep OOP |
|---|---|---|
| 10 000 moving entities | Fast | Slow |
| Adding new behaviour | Add component + system | Refactor hierarchy |
| Serialisation / save state | Trivial — plain data | Complex |
| Initial learning curve | Medium | Low |
Object Pooling
The garbage collector is a game developer's silent enemy. Every new Bullet() inside your game loop is a ticking time bomb — the GC will eventually pause your frame to clean it up, causing stutters even on 60 Hz targets. Object pooling pre-allocates a fixed set of objects and reuses them, keeping allocation entirely out of the critical path.
The pool hands out a dormant object when you fire a bullet, resets its state, and marks it active. On impact or exit, the object is returned to the free list — no memory freed, no allocation needed.
class ObjectPool { constructor(factory, initialSize = 64) { this.factory = factory; this.free = []; this.active = new Set(); // Pre-warm the pool for (let i = 0; i < initialSize; i++) this.free.push(this.factory()); } acquire() { const obj = this.free.pop() ?? this.factory(); // grow if empty this.active.add(obj); return obj; } release(obj) { if (!this.active.has(obj)) return; obj.reset(); this.active.delete(obj); this.free.push(obj); } releaseAll() { for (const obj of this.active) this.release(obj); } } // ── Bullet factory ────────────────────────────────── const bulletPool = new ObjectPool(() => ({ x: 0, y: 0, vx: 0, vy: 0, damage: 0, active: false, reset() { this.x = this.y = this.vx = this.vy = 0; this.active = false; } }), 256); // ── Usage inside the game loop ─────────────────────── function fireBullet(x, y, angle, spd) { const b = bulletPool.acquire(); b.x = x; b.y = y; b.vx = Math.cos(angle) * spd; b.vy = Math.sin(angle) * spd; b.active = true; b.damage = 10; } function updateBullets(dt) { for (const b of bulletPool.active) { b.x += b.vx * dt; b.y += b.vy * dt; if (isOffScreen(b)) bulletPool.release(b); } }
Never hold a reference to a pooled object after calling release(). The pool will hand it to someone else, and you'll be mutating a live object — a class of bug that's extremely hard to track down.
Pooling shines most for frequently spawned, short-lived objects: bullets, particles, explosions, damage numbers, audio clips. For long-lived, few-in-number objects the overhead isn't worth it. Profile first.
| Object Type | Pool? | Reason |
|---|---|---|
| Bullets / projectiles | Yes | High frequency, short lifespan |
| Particle effects | Yes | Hundreds per second |
| Audio buffer nodes | Yes | WebAudio node creation is expensive |
| Level entities (trees) | No | Long-lived, infrequently spawned |
| UI elements | Maybe | Only in long scroll lists |
Prototypal
Inheritance
Unlike class-based languages, JavaScript objects inherit directly from other objects via the prototype chain. Understanding this mechanism — not just using the class sugar on top of it — unlocks powerful game-specific patterns around shared behaviour, mixin composition, and ultra-fast method lookup.
The class keyword in ES6 is syntactic sugar — under the hood, class Foo extends Bar still wires up Foo.prototype.__proto__ === Bar.prototype. What makes prototypal inheritance uniquely powerful is runtime mutability: you can add a method to a prototype and every existing instance immediately gains it, with zero per-instance cost.
// ── Raw prototypal style (explicit) ──────────────── const gameObjectProto = { update(dt) { /* base tick */ }, destroy() { this.active = false; } }; const entityProto = Object.create(gameObjectProto); entityProto.move = function(dx, dy) { this.x += dx; this.y += dy; }; // ── Factory: no `new`, no class keyword ───────────── function createPlayer(x, y) { const player = Object.create(entityProto); player.x = x; player.y = y; player.hp = 100; player.active = true; player.jump = function() { this.vy = -400; }; return player; } // ── Mixin for shared behaviours ────────────────────── const damageable = { takeDamage(amount) { this.hp -= amount; if (this.hp <= 0) this.destroy(); } }; const shieldable = { takeDamage(amount) { const blocked = Math.min(this.shield, amount); this.shield -= blocked; damageable.takeDamage.call(this, amount - blocked); } }; // Apply mixin at runtime Object.assign(entityProto, damageable); // Upgrade specific instance const boss = createPlayer(512, 300); Object.assign(boss, shieldable, { hp: 1000, shield: 250 }); // ── Class syntax (same prototype wiring, cleaner) ─── class Bullet extends GameObject { constructor(x, y, vx, vy) { super(x, y); this.vx = vx; this.vy = vy; } update(dt) { this.x += this.vx * dt; this.y += this.vy * dt; if (isOffScreen(this)) this.destroy(); } }
V8 (and SpiderMonkey) assign hidden classes to objects. When all instances share the same shape — same properties added in the same order — method dispatch is monomorphic and JIT-compiled to near-native speed. Factory functions and Object.create() make this trivial to guarantee; ad-hoc property additions break it.
Deep prototype chains (5+ levels) hinder JIT optimisation and complicate debugging. For games with highly variable entity types, ECS composition beats deep inheritance every time. Use prototype chains for shallow, stable hierarchies (3 levels max) and reach for ECS when you need maximum flexibility.