Patterns
Common game patterns implemented with ECS. Each shows a typical game mechanic using entities, components, and systems.
Particles
Spawn short-lived particles with position, velocity, and lifetime. The lifetime system removes expired entities.
// Components
const Position = { x: [], y: [] }
const Velocity = { vx: [], vy: [] }
const Lifetime = { remaining: [] }
const Particle = {} // Tag
// Spawn system
const spawnParticle = (x, y) => {
const eid = addEntity(world)
addComponent(world, eid, Particle)
addComponent(world, eid, Position)
addComponent(world, eid, Velocity)
addComponent(world, eid, Lifetime)
Position.x[eid] = x
Position.y[eid] = y
Velocity.vx[eid] = (Math.random() - 0.5) * 4
Velocity.vy[eid] = (Math.random() - 0.5) * 4
Lifetime.remaining[eid] = 60 // frames
}
// Lifetime system - remove expired
const lifetimeSystem = (world) => {
for (const eid of query(world, [Lifetime])) {
Lifetime.remaining[eid]--
if (Lifetime.remaining[eid] <= 0) {
removeEntity(world, eid)
}
}
}Gravity
Apply gravity to entities with mass. Entities fall and bounce off the ground. Shows force accumulation pattern.
// Components
const Position = { x: [], y: [] }
const Velocity = { vx: [], vy: [] }
const Mass = { value: [] }
const Grounded = {} // Tag
const GRAVITY = 0.5
const GROUND_Y = 280
const BOUNCE = 0.7
// Gravity system
const gravitySystem = (world) => {
for (const eid of query(world, [Velocity, Mass])) {
Velocity.vy[eid] += GRAVITY * Mass.value[eid]
}
}
// Ground collision system
const groundSystem = (world) => {
for (const eid of query(world, [Position, Velocity])) {
if (Position.y[eid] >= GROUND_Y) {
Position.y[eid] = GROUND_Y
Velocity.vy[eid] *= -BOUNCE
if (Math.abs(Velocity.vy[eid]) < 0.5) {
Velocity.vy[eid] = 0
addComponent(world, eid, Grounded)
}
}
}
}Top-Down Movement
WASD controls with 8-directional movement. Input is stored in a component, then processed by the movement system.
// Components
const Position = { x: [], y: [] }
const Input = { x: [], y: [] } // -1 to 1
const Speed = { value: [] }
const Player = {} // Tag
// Input system - read keyboard state
const inputSystem = (world) => {
for (const eid of query(world, [Input, Player])) {
Input.x[eid] = 0
Input.y[eid] = 0
if (keys['KeyW']) Input.y[eid] -= 1
if (keys['KeyS']) Input.y[eid] += 1
if (keys['KeyA']) Input.x[eid] -= 1
if (keys['KeyD']) Input.x[eid] += 1
// Normalize diagonal
const len = Math.hypot(Input.x[eid], Input.y[eid])
if (len > 1) {
Input.x[eid] /= len
Input.y[eid] /= len
}
}
}
// Movement system
const movementSystem = (world) => {
for (const eid of query(world, [Position, Input, Speed])) {
Position.x[eid] += Input.x[eid] * Speed.value[eid]
Position.y[eid] += Input.y[eid] * Speed.value[eid]
}
}Steering Behaviors
Entities seek toward targets. Classic AI pattern: calculate desired velocity, steer toward it with limited force.
// Components
const Position = { x: [], y: [] }
const Velocity = { vx: [], vy: [] }
const Seek = { targetX: [], targetY: [] }
const MaxSpeed = { value: [] }
const MaxForce = { value: [] }
// Seek system - steer toward target
const seekSystem = (world) => {
for (const eid of query(world, [Position, Velocity, Seek])) {
// Desired velocity toward target
let dx = Seek.targetX[eid] - Position.x[eid]
let dy = Seek.targetY[eid] - Position.y[eid]
// Normalize and scale to max speed
const dist = Math.hypot(dx, dy)
if (dist > 0) {
dx = (dx / dist) * MaxSpeed.value[eid]
dy = (dy / dist) * MaxSpeed.value[eid]
}
// Steering = desired - current
let sx = dx - Velocity.vx[eid]
let sy = dy - Velocity.vy[eid]
// Limit steering force
const force = Math.hypot(sx, sy)
if (force > MaxForce.value[eid]) {
sx = (sx / force) * MaxForce.value[eid]
sy = (sy / force) * MaxForce.value[eid]
}
Velocity.vx[eid] += sx
Velocity.vy[eid] += sy
}
}Collision Detection
Simple circle-circle collision. When entities overlap, they bounce apart. Shows N×N query pattern.
// Components
const Position = { x: [], y: [] }
const Velocity = { vx: [], vy: [] }
const Radius = { value: [] }
// Collision system - O(n²) broad phase
const collisionSystem = (world) => {
const entities = query(world, [Position, Radius])
for (let i = 0; i < entities.length; i++) {
const a = entities[i]
for (let j = i + 1; j < entities.length; j++) {
const b = entities[j]
const dx = Position.x[b] - Position.x[a]
const dy = Position.y[b] - Position.y[a]
const dist = Math.hypot(dx, dy)
const minDist = Radius.value[a] + Radius.value[b]
if (dist < minDist && dist > 0) {
// Separate entities
const overlap = (minDist - dist) / 2
const nx = dx / dist
const ny = dy / dist
Position.x[a] -= nx * overlap
Position.y[a] -= ny * overlap
Position.x[b] += nx * overlap
Position.y[b] += ny * overlap
// Bounce velocities (simple)
if (hasComponent(world, a, Velocity)) {
Velocity.vx[a] = -nx * 2
Velocity.vy[a] = -ny * 2
}
}
}
}
}Spawner Pattern
Entities that spawn other entities on a timer. Common for enemy waves, particle emitters, projectile launchers.
// Components
const Position = { x: [], y: [] }
const Spawner = {
interval: [], // frames between spawns
timer: [], // current countdown
prefab: [] // what to spawn (entity ID)
}
// Spawner system
const spawnerSystem = (world) => {
for (const eid of query(world, [Spawner, Position])) {
Spawner.timer[eid]--
if (Spawner.timer[eid] <= 0) {
// Reset timer
Spawner.timer[eid] = Spawner.interval[eid]
// Spawn entity at spawner position
const spawned = addEntity(world)
addComponent(world, spawned, Position)
addComponent(world, spawned, Velocity)
Position.x[spawned] = Position.x[eid]
Position.y[spawned] = Position.y[eid]
// Random direction
const angle = Math.random() * Math.PI * 2
Velocity.vx[spawned] = Math.cos(angle) * 2
Velocity.vy[spawned] = Math.sin(angle) * 2
}
}
}
// Setup: create a spawner
const spawner = addEntity(world)
addComponent(world, spawner, Position)
addComponent(world, spawner, Spawner)
Position.x[spawner] = 200
Position.y[spawner] = 150
Spawner.interval[spawner] = 30
Spawner.timer[spawner] = 30Health & Damage
Combat basics: health pools, taking damage, death handling. Uses a queue pattern to safely defer entity removal.
// Components
const Health = { current: [], max: [] }
const Damage = { amount: [] } // Incoming damage
const Dead = {} // Tag
// Queue for deferred removals (never mutate ECS in observers!)
const removalQueue: number[] = []
// Apply damage system
const damageSystem = (world) => {
for (const eid of query(world, [Health, Damage])) {
Health.current[eid] -= Damage.amount[eid]
// Clear damage after applying
removeComponent(world, eid, Damage)
// Check for death
if (Health.current[eid] <= 0) {
Health.current[eid] = 0
addComponent(world, eid, Dead)
}
}
}
// Observer only queues removal and spawns effects (no ECS mutation!)
observe(world, onAdd(Dead), (eid) => {
spawnParticles(Position.x[eid], Position.y[eid], 10)
removalQueue.push(eid)
})
// Drain removal queue at a controlled point in game loop
const cleanupSystem = (world) => {
while (removalQueue.length > 0) {
const eid = removalQueue.pop()!
removeEntity(world, eid)
}
}
// Deal damage helper
const dealDamage = (target, amount) => {
if (hasComponent(world, target, Health)) {
addComponent(world, target, Damage)
Damage.amount[target] = amount
}
}State Machine
Tag components as states. Only one state active at a time. State systems only process entities in their state.
// State components (tags)
const Idle = {}
const Walking = {}
const Jumping = {}
const Falling = {}
// State data
const StateTimer = { value: [] }
// Idle system - check for transitions
const idleSystem = (world) => {
for (const eid of query(world, [Idle, Input])) {
if (Input.x[eid] !== 0 || Input.y[eid] !== 0) {
removeComponent(world, eid, Idle)
addComponent(world, eid, Walking)
}
if (Input.jump[eid] && hasComponent(world, eid, Grounded)) {
removeComponent(world, eid, Idle)
addComponent(world, eid, Jumping)
StateTimer.value[eid] = 15 // Jump frames
}
}
}
// Jump system
const jumpSystem = (world) => {
for (const eid of query(world, [Jumping])) {
Velocity.vy[eid] = -8 // Jump force
StateTimer.value[eid]--
if (StateTimer.value[eid] <= 0) {
removeComponent(world, eid, Jumping)
addComponent(world, eid, Falling)
}
}
}
// Falling system
const fallingSystem = (world) => {
for (const eid of query(world, [Falling])) {
if (hasComponent(world, eid, Grounded)) {
removeComponent(world, eid, Falling)
addComponent(world, eid, Idle)
}
}
}Flocking
Boids algorithm: separation, alignment, cohesion. Each entity steers based on neighbors within perception radius.
// Components
const Position = { x: [], y: [] }
const Velocity = { vx: [], vy: [] }
const Boid = { perception: [] }
// Flocking system
const flockingSystem = (world) => {
const boids = query(world, [Boid, Position, Velocity])
for (const eid of boids) {
let sepX = 0, sepY = 0, sepCount = 0
let aliX = 0, aliY = 0
let cohX = 0, cohY = 0
let neighborCount = 0
const perception = Boid.perception[eid]
for (const other of boids) {
if (other === eid) continue
const dx = Position.x[other] - Position.x[eid]
const dy = Position.y[other] - Position.y[eid]
const dist = Math.hypot(dx, dy)
if (dist < perception) {
neighborCount++
// Separation - steer away from close neighbors
if (dist < perception * 0.5) {
sepX -= dx / dist
sepY -= dy / dist
sepCount++
}
// Alignment - match neighbor velocities
aliX += Velocity.vx[other]
aliY += Velocity.vy[other]
// Cohesion - steer toward center
cohX += Position.x[other]
cohY += Position.y[other]
}
}
if (neighborCount > 0) {
// Apply forces (weighted)
Velocity.vx[eid] += sepX * 0.05
Velocity.vy[eid] += sepY * 0.05
Velocity.vx[eid] += (aliX / neighborCount - Velocity.vx[eid]) * 0.02
Velocity.vy[eid] += (aliY / neighborCount - Velocity.vy[eid]) * 0.02
Velocity.vx[eid] += (cohX / neighborCount - Position.x[eid]) * 0.01
Velocity.vy[eid] += (cohY / neighborCount - Position.y[eid]) * 0.01
}
}
}