An entity component system for a roguelike

A Monster class, a Player class and a base class they share work until you want a door you can attack. I use a small entity component system instead, about a hundred lines of TypeScript, with one downside you should know about.

Every roguelike starts with the same class diagram. There's an Entity with a position and a glyph. Actor extends it with health and a turn. Player and Monster extend that. Items get their own branch. It's tidy and fits on a napkin, and it lasts until the first thing that doesn't fit.

For me that thing is always a door. A door has a position and a glyph, so it's an Entity. It can be locked, opened, and if you want to be able to chop it down, attacked. Now it needs health, and health lives on Actor, and an Actor takes turns, and doors don't. Mixins, flags, a canTakeTurn boolean that's false on doors. Then a mimic shows up: an item that is a monster, which is two branches of the tree at once.

Composition instead

An entity component system turns the tree on its side. An entity is just an id. A component is a bag of data attached to an id: a position, a glyph, a health pool. A system is a function that loops over every entity that has a given set of components and does one job with them.

A door is an id with a position, a glyph and health. A mimic is an id with a position, an item's glyph, health and an AI. The player is an id with a position, a glyph, health, and a tag that says the keyboard drives this one. No tree, no special cases, and the question "can I attack it" becomes "does it have health".

ECS is usually sold on performance, cache friendly arrays of components for games with ten thousand particles. A turn based game with forty monsters doesn't care about any of that. I use it for the composition, and the code below is built for that, not for speed.

A world in a hundred lines

The component types go in one place, and the world is generic over them, so get and set know what shape each component has.

ts
// src/ecs/components.ts
export type Components = {
  position: { x: number; y: number };
  glyph: { char: string; color: string };
  health: { hp: number; max: number };
  energy: { current: number; speed: number };
  regenerate: { perTurn: number };
  ai: { kind: 'wander' | 'chase' };
  player: true; // a tag: present or not, no data
  blocks: true;
};
ts
// src/ecs/world.ts
export type Entity = number;

export class World<C extends Record<string, unknown>> {
  private nextId: Entity = 1;
  private stores = new Map<keyof C, Map<Entity, unknown>>();

  spawn(): Entity {
    return this.nextId++;
  }

  // Attach a component. The name picks the type, so a glyph can't hold hit points.
  set<K extends keyof C>(entity: Entity, name: K, data: C[K]): this {
    let store = this.stores.get(name);
    if (!store) {
      store = new Map();
      this.stores.set(name, store);
    }
    store.set(entity, data);
    return this;
  }

  get<K extends keyof C>(entity: Entity, name: K): C[K] | undefined {
    return this.stores.get(name)?.get(entity) as C[K] | undefined;
  }

  has(entity: Entity, name: keyof C): boolean {
    return this.stores.get(name)?.has(entity) ?? false;
  }

  remove(entity: Entity, name: keyof C): void {
    this.stores.get(name)?.delete(entity);
  }

  despawn(entity: Entity): void {
    for (const store of this.stores.values()) store.delete(entity);
  }

  // Every entity that has all of the named components
  *query(...names: Array<keyof C>): Generator<Entity> {
    const stores = names.map((name) => this.stores.get(name));
    if (stores.some((store) => !store)) return;
    const [first, ...rest] = stores as Array<Map<Entity, unknown>>;
    for (const entity of first.keys()) {
      if (rest.every((store) => store.has(entity))) yield entity;
    }
  }
}

A Map per component type, keyed by entity id. query walks the first store and checks the rest, which is fine at this size. spawn hands out ids, despawn sweeps every store.

Systems

A system is a function. The render system draws everything that has a position and a glyph, and doesn't know what any of them are.

ts
// src/systems/render.ts
import type { World } from '../ecs/world';
import type { Components } from '../ecs/components';

export function renderSystem(world: World<Components>, draw: (x: number, y: number, char: string, color: string) => void): void {
  for (const entity of world.query('position', 'glyph')) {
    const { x, y } = world.get(entity, 'position')!;
    const { char, color } = world.get(entity, 'glyph')!;
    draw(x, y, char, color);
  }
}
ts
// src/spawn.ts
const orc = world.spawn();
world
  .set(orc, 'position', { x: 10, y: 4 })
  .set(orc, 'glyph', { char: 'o', color: 'green' })
  .set(orc, 'health', { hp: 8, max: 8 })
  .set(orc, 'energy', { current: 0, speed: 10 })
  .set(orc, 'ai', { kind: 'chase' })
  .set(orc, 'blocks', true);

// A door is the same thing with fewer parts, and chopping it down just works
const door = world.spawn();
world
  .set(door, 'position', { x: 14, y: 4 })
  .set(door, 'glyph', { char: '+', color: 'brown' })
  .set(door, 'health', { hp: 20, max: 20 })
  .set(door, 'blocks', true);

The orc and the door are two calls to spawn with different components. Nothing in the render system, the combat system or the movement system knows which is which. When I later want a door that regrows, I give it the same regenerate component a troll has, and the regeneration system picks it up on the next turn without a line changed.

ts
// src/systems/regenerate.ts
export function regenerateSystem(world: World<Components>): void {
  for (const entity of world.query('health', 'regenerate')) {
    const health = world.get(entity, 'health')!;
    const { perTurn } = world.get(entity, 'regenerate')!;
    health.hp = Math.min(health.max, health.hp + perTurn);
  }
}

Where it bites

The behaviour is now spread across systems, and that has a price. "Where does damage get applied" is a search for health across the systems folder, not a method on a class. When something is wrong with one entity you can't open its class, because it has none. I keep a debug command that dumps every component of the entity under the cursor, and I use it a lot.

Bob Nystrom gave a talk at Roguelike Celebration in 2018 titled "Is There More to Game Architecture than ECS?", and the answer is yes. His roguelike Hauberk uses components only where they earn it, and his book has a chapter on the component pattern that's about composition, not about arrays. Caves of Qud is built around components and is the most moddable roguelike there is. Both work. What I'm after is composition, and ECS is one way to get it. The purist version, with every piece of data in a component and no methods anywhere, is optional.

Read more here: