Docs/@katforge/anvil

@katforge/anvil

An ECS (Entity-Component-System) library. Entities are plain integers. Components are typed data attached to entities via decorators. Systems query and mutate component data. Used by Gear Goblins for all game state.

shell
npm install @katforge/anvil

Core concepts

flowchart LR
   World["World"]
   E["Entity (int)"]
   C["Component (typed data)"]
   Q["Query (filter)"]
   S["System (logic)"]

   World -->|spawn| E
   E -->|attach| C
   Q -->|match| E
   S -->|read/write| Q
   World -->|tick| S

Define components

TypeScript
import { int, string, float, boolean, object } from '@katforge/anvil';

class Position {
   @float x = 0;
   @float y = 0;
}

class Health {
   @int current = 100;
   @int max = 100;
}

class Name {
   @string value = '';
}

class Inventory {
   @object items = [];
}

Component decorators (@int, @float, @string, @boolean, @object) define the storage layout. Typed columns enable batch iteration without boxing.

Create a world

TypeScript
import { World } from '@katforge/anvil';

const world = new World ();

// Spawn an entity with components
const player = world.spawn (
   new Position (),
   new Health ({ current: 80, max: 100 }),
   new Name ({ value: 'Grimlock' })
);

Query

TypeScript
// All entities with Position AND Health
const q = world.query (Position, Health);

for (const [ pos, hp ] of q) {
   pos.x += 1;
   if (hp.current <= 0) { /* ... */ }
}

Queries are reactive: adding or removing a component from an entity automatically updates every matching query.

Lifecycle observers

TypeScript
import { Added, Removed, Changed } from '@katforge/anvil';

// React to new entities entering a query
world.observe (Added, Position, (entity, pos) => {
   console.log ('new positioned entity', entity);
});

Systems

A system is just a function that runs against the world each tick. Compose them in the order behavior should resolve:

TypeScript
function movement (world: World, dt: number) {
   for (const [ pos, vel ] of world.query (Position, Velocity)) {
      pos.x += vel.dx * dt;
      pos.y += vel.dy * dt;
   }
}

function regen (world: World, dt: number) {
   for (const [ hp ] of world.query (Health)) {
      hp.current = Math.min (hp.max, hp.current + 5 * dt);
   }
}

// In your game loop:
function tick (dt: number) {
   movement (world, dt);
   regen    (world, dt);
}

Anvil doesn't ship a scheduler — @katforge/forge owns the loop. Anvil only owns storage, queries, and observers.

Relations

TypeScript
const ChildOf = world.relation ();

const parent = world.spawn ();
const child  = world.spawn ();
world.relate (child, ChildOf, parent);

// Query all children of a specific parent
const children = world.query (ChildOf, parent);

Why ECS

Traditional OOP hierarchies (Player extends Character extends Entity) break down when cross-cutting concerns pile up. ECS composes behavior via data: an entity is just an integer; its capabilities are determined by which components are attached. Adding "poisoned" to a character is world.attach (entity, new Poisoned ()), not a class inheritance change.