Skip to content

Entity Component System

Try the interactive example ↓

An Entity Component System for game state, simulations, and any fleet of objects with typed, queryable state: schema-validated components on entities, derived archetypes, parent-child hierarchies, queries with field-level filters, shared per-world resources, and systems driven by a tick coordinator.

When to reach for it: game worlds, agent or physics simulations, device fleets - anywhere many objects share typed state shapes that you query by shape ("all entities with Position and Velocity") and mutate with optimistic locking.

When not to: plain JSON storage keyed by name belongs to KV Store; distributed locks and compare-and-swap over a shared value belong to Distributed State.

Concepts

  • Index - a namespace isolating one ECS world, passed as -x/--index on almost every command. Everything below except schemas lives inside an index; two indexes never see each other's entities.
  • Schemas are global and mandatory - a component type must have a JSON Schema before any entity can carry it. Global (no index) and versioned.
  • Entity - an ID plus components (JSON documents of schema-defined types), tags, an optional parent, and a version that increments on every mutation (optimistic locking).
  • Archetype - the set of component types on an entity, identified by a hash. Derived automatically; you never create or name one.
  • Resource - shared singleton JSON per index (world config, clocks), not attached to any entity, with versioned updates.
  • Systems and the coordinator - external workers matched to entities by a stored query and driven by a tick loop; they have their own page.

Schemas

Schema commands take no index. Component data is validated against the latest schema version at write time - verified by relaxing a required field in a v2 and watching a previously rejected write succeed:

snug ecs schema create --type Position --version 1 \
  --schema '{"type":"object","properties":{"x":{"type":"number"},"y":{"type":"number"}},"required":["x","y"]}'
snug ecs schema list
snug ecs schema get --type Position              # latest; add --version 1 for a specific one
snug ecs schema delete --type Position --version 1 --force

schema list pages through every component type's versions, newest version first within a type; narrow it with --filter 'component_type[eq]=Position' or filter[version][gte]=2. A component type holds at most ECS_MAX_SCHEMAS_PER_COMPONENT versions (default 100); registering one more fails with 400 too_many_schemas until an old version is deleted.

Writing a component type that has no schema fails with 404 schema_not_found; data that violates the schema fails with 400 schema_validation_failed and the validator's message ("y" is a required property).

Entities and components

snug ecs entity create -x game1 --id player1 \
  --components '{"Position":{"x":10,"y":20},"Health":{"current":100,"max":100}}' \
  --tags player,active
snug ecs component set -x game1 --entity player1 --type Velocity --data '{"x":1,"y":0}'
snug ecs component get -x game1 --entity player1 --type Velocity
snug ecs component remove -x game1 --entity player1 --type Velocity
snug ecs entity get -x game1 --id player1
snug ecs entity tags -x game1 --id npc1 --tags hostile,elite
snug ecs entity delete -x game1 --id player1 --force

Creating an ID that already exists in the index fails with 409 entity_already_exists. entity tags replaces the whole tag set with the supplied list - it does not append.

Batch operations create from a JSON array and delete by query, not by ID list. The CLI's batch-delete exposes only --has-all; the HTTP endpoint accepts a full query object:

snug ecs entity batch-create -x game1 --entities \
  '[{"entity_id":"crate1","components":{"Position":{"x":0,"y":0}}},{"entity_id":"crate2","components":{"Position":{"x":1,"y":1}},"tags":["loot"]}]'
snug ecs entity batch-delete -x game1 --has-all Position --limit 100 --force

Queries

The CLI matches on component presence, with --count, a --sort, and the list paging options (--limit, --cursor from page.next_cursor, --page, --total):

snug ecs query -x game1 --has-all Position,Velocity --limit 10
snug ecs query -x game1 --has-all Health --sort -Health.current --limit 20 --total
snug ecs query -x game1 --has-all Position --has-none Velocity --count

sort names <component>.<field>, - first for descending; without it the results come in entity id order, and equal values keep that order. A cursor resumes after the last entity served, so deleting an entity does not shift the pages after it. The HTTP endpoint adds field-level filters (eq|ne|lt|lte|gt|gte|between|in|contains) and select, which the CLI does not surface. The query lives under a where key whose four arrays are all required, even when empty; the body takes sort, limit, cursor, page, and total beside it:

curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"where":{"has_all":["Health"],"has_any":[],"has_none":[],"filters":[{"component":"Health","field":"current","op":"lt","value":50}]}}' \
  http://localhost:4000/api/v1/ecs/game1/query
{
  "status": 200,
  "msg": "OK",
  "data": {
    "entities": [
      {
        "entity_id": "npc2",
        "components": {
          "Health": { "current": 30, "max": 30 },
          "Position": { "x": 50, "y": 8 }
        },
        "tags": ["npc"],
        "parent": null,
        "archetype_hash": "92ba98566c2e1f6a",
        "version": 1
      }
    ],
    "page": { "has_more": false }
  }
}

Archetypes

Read-only views over the component-set groupings the server derives:

snug ecs archetype list -x game1
snug ecs archetype get -x game1 --hash 92ba98566c2e1f6a

The hash is a pure function of the component set - verified by adding a component (hash changed) and removing it again (original hash returned). The archetype, resource, and subscription lists are lists: archetypes filter on components and entity_count, resources on name (including prefix), subscriptions on type and created_at.

Hierarchy

Single parent, many children. Set the parent at create time with --parent, or later:

snug ecs hierarchy set-parent -x game1 --id sword1 --parent player1
snug ecs hierarchy children -x game1 --id player1
snug ecs hierarchy remove-parent -x game1 --id sword1

children pages in attach order (--limit, --cursor, --total). Setting a new parent replaces the old one; a parent chain that loops back is rejected with 422 circular_reference.

Resources

snug ecs resource create -x game1 --name GameConfig --data '{"gravity":9.8,"max_players":8}'
snug ecs resource get -x game1 --name GameConfig
snug ecs resource update -x game1 --name GameConfig --data '{"gravity":10.0,"max_players":8}' --expected-version 1
snug ecs resource delete -x game1 --name GameConfig --force

--expected-version is optimistic locking: a stale version fails with 409 version_conflict (expected 1, found 2) and leaves the resource untouched. Systems can request resources in their tick payloads via --include-resources - see Systems and the coordinator.

Change subscriptions

Subscription records declare what to watch - one entity, one component type everywhere, or a query:

snug ecs subscription create -x game1 --subscription-type entity --entity-id player1
snug ecs subscription create -x game1 --subscription-type component --component-type Position
snug ecs subscription create -x game1 --subscription-type query \
  --query '{"has_all":["Position"],"has_any":[],"has_none":[],"filters":[]}'
snug ecs subscription delete -x game1 --subscription-id <sub-id>

Notifications are delivered over the WebSocket endpoint GET /api/v1/ecs/{index}/subscribe, authenticated with a Bearer header or a ?token= query parameter - verified: the handshake upgrades (101) with a token and is rejected (401) without. The CLI manages only the records; the message protocol is in the Subscriptions API reference.

Limits and configuration

Defaults: 64 components per entity, 64 KiB per component payload, batches of 100, query pages of 100 (clamped at 1000), and a 100 ms default tick - all tunable via the ECS_* variables.

Reference

Interactive example

Model objects as entities with typed components, then query and update them through systems. This works well for game worlds, simulations, and device fleets.

An illustrative example that runs in your browser. No account or API calls required.