Skip to content

ECS Core

The API of the game’s own small ECS core: how systems are written, how they get their data, and how the scheduler checks them. The rules the core enforces (phases, one writer per field, next-tick events) are in simulation-architecture.md; why the game writes its own core instead of using a library is in simulation-architecture.md and tech-research.md.

The core follows bevy_ecs 0.20.1, checked against its source and docs on November 1, 2026. It keeps Bevy’s names and meanings wherever it can, so that a later move to Rust is a line-by-line rewrite. Where it differs, this doc says so and says why. Sketches are TypeScript; the exact signatures are settled when the core is written.

Everything here is part of step 1 of the build order. Choices the designer made are Decided; the rest are Tentative until the first Harvester spike.

At a glance

BevyHereNotes
#[derive(Component)] struct Position {…}const Position = Component<{…}>("Position")A run-time token that carries the type
#[derive(Resource)]const Tick = Resource<{…}>("Tick")
#[derive(Message)]const Damaged = Message<{…}>("Damaged")Read on the next tick only
fn move_units(q: Query<…>, t: Res<Tick>)system("moveUnits", { q: Query(…), t: Res(Tick) }, ({ q, t }) => …)The spec object is the parameter list
&T, &mut T, Option<&T>, Has<T>, EntityT, Mut(T), Opt(T), Has(T), Entity
With<T>, Without<T>With(T), Without(T)
Res<T>, ResMut<T>Res(T), ResMut(T)
CommandsCommands({ inserts, removes })Declares what it adds and removes
MessageReader<M>, MessageWriter<M>MessageReader(M), MessageWriter(M)
System sets chained in FixedUpdateSchedule([Phase(…), …])
Ambiguity detection (off by default)The access check (always on)Filter-aware, like Bevy’s

Components Tentative

  • A component is declared once, as a token that carries its type: export const Position = Component<{ x: number; y: number }>("Position"). A marker has no data: export const Deployed = Component("Deployed"). The name string is used in error messages and in the state sent to clients.
  • A component’s value is always a plain object, never a bare number, so that writes happen in place the way &mut works. Bevy’s struct Health(i32) becomes Health<{ value: number }>. Values hold numbers, strings, booleans, arrays, plain objects and entity ids: nothing that can’t be written as JSON.
  • Calling a token builds a value, like a Rust struct literal: Position({ x: 3, y: 7 }). A recipe is a function that returns a list of these.
  • Entity is an entity’s id, an increasing integer that is never reused (first-test.md). Its type is branded so an id can’t be mixed up with a count. Components refer to other entities by storing their id, such as a Harvester’s home Depot.

Systems Decided

A system declares its parameters as an object of specs. The same object tells the scheduler at run time what the system reads and writes, and tells TypeScript at compile time the types of the arguments the system receives. This is the core problem the API solves: Bevy reads access from a function’s parameter types, and TypeScript’s types don’t exist at run time.

export const moveUnits = system("moveUnits", {
movers: Query([Mut(Position), Mover], Without(Asleep)),
tick: Res(Tick),
}, ({ movers, tick }) => {
for (const [position, mover] of movers.iterMut()) {
position.x += 1; // Position is writable
mover.goal = …; // compile error: Mover is read-only
}
});

The Bevy equivalent is fn move_units(mut movers: Query<(&mut Position, &Mover), Without<Asleep>>, tick: Res<Tick>).

  • A system can only reach data through its parameters. It gets no World, so undeclared access, and structural changes outside the command buffer, have no API to go through.
  • The name string is a verb phrase (naming) and appears in the access check’s errors.
  • Considered: specs as an array received as positional arguments (closest to a Rust signature, but each name sits apart from its type, and two parameters of the same type can be swapped without an error); reads and writes lists next to a system that queries the world itself (two sources of truth that drift apart).

Left out Decided

  • No Local<T>. All state lives in the world, so saving, sending the whole state and moving a world to a server never miss anything, and determinism can’t depend on hidden memory. Scratch data is a resource or is rebuilt each tick.
  • No exclusive systems (Bevy’s &mut World parameter). Building the world is a plain function that runs before the first tick. Players’ commands are applied by ordinary systems in the Commands phase (see Messages). Every system goes through the access check and the command buffer.

Queries

Terms and filters Tentative

TermItemAccess
CDeepReadonly<C>Reads C; requires C
Mut(C)CWrites C; requires C
Opt(C), Opt(Mut(C))the value, or undefinedReads or writes C; doesn’t require it
Has(C)booleanNone
Entitythe idNone
With(C), Without(C)(filter)None
  • Items are tuples in spec order: for (const [pos, mover] of movers.iterMut()), like Bevy’s for (mut pos, mover) in &mut movers. A query with one term yields the value itself, as in Bevy.
  • iter() always gives read-only items; iterMut() gives writable ones for Mut terms, the same split as Bevy.
  • Or, Added and Changed are left out (see Left out).

Read-only is checked by the compiler Decided

Read terms are typed DeepReadonly<C>, so mover.path.push(…) is an error as well as mover.goal = …. A lint rule bans as and any in the sim and shared packages, which closes the usual ways around it. There’s no run-time guard. Considered: proxies in development builds that throw on writes (they catch casts, but slow every field access in tests and balance sweeps) and a shallow Readonly (writes to nested data such as Storage slots would still compile).

Methods

  • get(id) and getMut(id) return the item, or undefined if the entity doesn’t exist or doesn’t match the query Decided. Bevy returns a Result; the port turns if (!x) into let Ok(x) = q.get(e) else {…}.
  • single() returns the item if exactly one entity matches, otherwise undefined Tentative. For things like the one Crashed Ship.
  • iter().sortBy(key) for loops whose order is a game rule, such as the handout’s earliest-due-first Tentative. Bevy has iter().sort_by(…).

Iteration order Decided

Every query iterates in ascending entity id (simulation-architecture.md, rule 3). Each component’s store keeps its ids sorted, and a query walks the smallest store among the components it requires. Bevy doesn’t guarantee any order; see Porting to Rust.

Two queries in one system Tentative

Two queries in one system that could both reach the same component, with at least one writing it, are an error when the system is defined. This is Bevy’s B0001 panic, and it uses the same filter rule as the access check. The fix is a filter that keeps the two apart. Bevy’s ParamSet is left out.

Resources

  • A resource is a token like a component: export const Tick = Resource<{ value: number }>("Tick") Tentative. Res(Tick) reads it (deep read-only) and ResMut(Tick) writes it. The access check treats resources like components.
  • Every resource exists from the start Tentative. The world is created with all of them, so Res never comes back empty and Option<Res<T>> isn’t needed.
  • The core owns Tick. It adds one at the start of every tick, and systems can only read it.
  • Bevy 0.19 made resources components on hidden entities. That’s an internal change and doesn’t affect this API.

Commands and sync points

Declared adds and removes Decided

Commands declares which components the system adds and removes: Commands({ inserts: [Asleep], removes: [Deployed] }). The access check counts these as writes. In development builds, the buffer refuses to insert or remove anything that wasn’t declared. Spawning and despawning are exempt: a new entity can’t clash with anything, and a despawn is handled with tryInsert (below).

This closes a gap in Bevy, where Commands declares no access. Without it, two systems in one phase could add and remove the same marker, and the result would depend on the order they’re listed in, which rule 2 forbids. Considered: undeclared, like Bevy (adding and removing components stays outside the check); undeclared, with the sync point flagging clashes in development builds (only caught when the data happens to produce one).

Using commands Tentative

const ghost = commands.spawn(Position({ x, y }), Ghost({ recipe: "Depot" }));
crew.target = ghost.id(); // the id exists at once
commands.entity(id).insert(Asleep()); // declared in inserts
commands.entity(id).remove(Deployed); // declared in removes
commands.entity(id).despawn();
  • spawn returns EntityCommands, and its id is ready at once, as in Bevy. It can be stored or used in more commands straight away. The new entity’s components are visible to queries only after the sync point.
  • A command that targets a missing entity behaves as in Bevy: insert and remove throw, because that’s a bug; despawning an entity that’s already gone does nothing; tryInsert and tryRemove exist for legitimate races, such as an entity destroyed in the same phase. Since ids are never reused, a stored id never points at the wrong entity.

Sync points Tentative

Commands are applied at the end of each phase (simulation-architecture.md, rule 1): in the order the systems are listed in the phase, and each system’s commands in the order it issued them. The result is deterministic. Bevy applies buffers in its schedule’s sort order, which is only fixed for ordered systems; see Porting to Rust.

Messages

What Bevy does: MessageWriter appends immediately, and Bevy swaps two buffers at the start of every frame, so a message is readable in the frame it was written and the next one. A reader ordered after the writer sees it in the same frame. This game’s rule is stricter on purpose: a message produced on tick T is read on T+1 and gone after T+1 (States and events), so system order never decides whether a reaction sees it.

  • A message type is a token: export const Damaged = Message<{ target: Entity; amount: number }>("Damaged") Tentative. Payloads are plain objects, because they also go to clients.
  • MessageWriter(Damaged).write(value) appends to this tick’s buffer. MessageReader(Damaged).read() iterates last tick’s buffer and nothing else Tentative. Every reader sees every message; there’s no cursor and nothing is consumed. MessageMutator is left out.
  • The core swaps the buffers at the start of each tick, dropping last tick’s messages. That’s the job Bevy’s message_update_system does in First. A reader and a writer of the same type never clash, even in one phase, because they touch different buffers.
  • Several systems in one phase may write the same message type Decided. Messages are ordered by phase, then by the writing system’s place in the phase list, then by the order they were written. A reader whose result could depend on that order sorts them by a key of its own (rule 3). Considered: one writer per message type per phase, which is stricter and matches Bevy’s check (its MessageWriter takes write access to Messages<M>), but forces damage from several sources through separate types or one funnelling system. If order-dependent readers turn up, tests can shuffle message order within a tick to find them.
  • Each tick’s state message to clients carries the messages written on that tick (runtime-and-sync.md).

Players’ commands aren’t ECS messages Tentative, because they take effect on the same tick, not the next. The host puts them in an input resource, PlayerCommands, before the tick. Systems in the Commands phase apply them, one system per kind of command, and each writes an applied-or-rejected CommandResult message that goes out with that tick’s state.

The schedule Tentative

The schedule is one file that lists every system in order (The schedule):

export const tick = Schedule([
Phase("Commands", [applyPlaceGhostCommands, applyCancelGhostCommands, applyToggleCommands, …]),
Phase("Sense", [applyDamage, …]),
Phase("Decide", [updateHarvesterBrains, updateCrewBrains]),
Phase("Arbitrate", [arbitrateMovement]),
Phase("Act", [moveUnits, advanceConstruction]),
Phase("Settle", [unloadCargo]),
]);

One tick, in order:

  1. The core adds one to Tick.
  2. The message buffers swap.
  3. Each phase runs its systems in list order, then applies their commands.
  4. In development builds, after each phase, every number in every component and resource is checked to be whole (below).

The Cleanup phase is dropped Decided. Its only job was clearing old messages, which the core now does at the start of the tick. It comes back when a system needs end-of-tick work.

Within a phase there’s no before/after; systems run in list order, and by rule 2 that order doesn’t change the result. Two systems that need an order between them belong in different phases.

The access check Decided

When the schedule is built, the core compares every pair of systems in each phase and refuses to start if they clash, naming both systems and the component: Phase Act: moveUnits writes Position, which advanceConstruction reads. Because commands are applied after the phase, adding or removing a component never collides with reading or writing its data; only two structural changes to the same component can disagree.

Reads XWrites X (Mut, ResMut)Inserts or removes X
Reads Xfineclashfine
Writes Xclashclashfine
Inserts or removes Xfinefineclash
  • It’s filter-aware, like Bevy’s. Two accesses don’t clash if one requires a component the other excludes. C, Mut(C) and With(C) require C; Without(C) excludes it. Without Or this is a simple set comparison. The same rule applies to two queries in one system. Considered: component-level only (any two writes clash), which enforces one writer per field literally but refuses code Bevy accepts. The convention that a field has one writing system is kept by review.
  • It’s in the first test, written before the first system, because the first systems set the patterns the rest copy.
  • It runs in every build, since it runs once at start-up and costs almost nothing. Bevy’s equivalent, ambiguity detection, is off by default. Bevy’s exemptions (ambiguous_with, allow_ambiguous_component) are left out; a clash is fixed by moving a system to another phase or splitting its queries with filters.

Development-build checks Decided

  • Whole numbers: after each phase, every number in every component and resource must be whole. The error names the entity, component, field and phase. It also catches NaN and Infinity. Considered: a branded Int type, which the compiler checks but which needs a wrapper around every literal and every arithmetic result, because TypeScript has no integer type.
  • Declared commands: inserting or removing a component that Commands didn’t declare throws.

Left out

Bevy featureInstead, for nowAdd when
Change detection (Added, Changed, Ref)The whole state is sent each tickSync moves to diffs. TypeScript can’t see writes the way Bevy’s DerefMut does, so this needs its own design (open-questions.md).
Relationships (ChildOf/Children)A plain Entity field; get returns undefined once the target is gone, and the system holding the id deals with itMany-to-many links or cascading despawns become common
Run conditions (run_if), Single/Populated skippingThe system returns early—
States (OnEnter/OnExit)Game state lives in components—
Required components (#[require])Recipes list every componentRecipes start repeating themselves
Or, ParamSet, Local, exclusive systems, MessageMutator, Option<Res>See the sections above—
Ordering within a phase, ambiguity exemptionsList order; move systems between phasesA real case appears
Several schedules, Startup, fixed timeOne tick schedule; setup is a plain function; the tick count is the clock—
Running systems in parallelSingle-threaded—

Observers and hooks: excluded on principle Decided

Observers and component hooks run code immediately when something is triggered, added or removed. That breaks the two guarantees the sim rests on: data moves forward one phase at a time, and reactions come one tick later. Adding them would be a change to the design, not just to the code.

Worked example: unloading at the Depot

The unloading system writes two kinds of Storage in one system. The filters keep the two queries apart, so the check accepts it.

export const unloadCargo = system("unloadCargo", {
harvesters: Query([Entity, Mut(Storage), HarvesterBrain], Without(Depot)),
depots: Query([Mut(Storage)], With(Depot)),
unloaded: MessageWriter(Unloaded),
}, ({ harvesters, depots, unloaded }) => {
for (const [id, cargo, brain] of harvesters.iterMut()) {
if (brain.state !== "unloading") continue;
const store = depots.getMut(brain.home);
if (!store) continue; // the home Depot is gone
const amount = moveStacks(cargo, store);
if (amount > 0) unloaded.write({ unit: id, amount });
}
});

The same system in Bevy:

fn unload_cargo(
mut harvesters: Query<(Entity, &mut Storage, &HarvesterBrain), Without<Depot>>,
mut depots: Query<&mut Storage, With<Depot>>,
mut unloaded: NextTickWriter<Unloaded>, // the port's own message type, below
) {
for (id, mut cargo, brain) in &mut harvesters { // sorted by StableId in the port
if brain.state != BrainState::Unloading { continue; }
let Ok(mut store) = depots.get_mut(brain.home) else { continue };
let amount = move_stacks(&mut cargo, &mut store);
if amount > 0 { unloaded.write(Unloaded { unit: id, amount }); }
}
}

Running a world Tentative

Systems never see the world. Setup code, the host and tests use it directly. The whole flow, as a test that runs, is example.test.ts.

const world = new World({ resources: [Settings({ beaconAt: 3 }), Census({ beacons: 0 })] });
const unit = world.spawn(Position({ x: 0 }), Mover({ goal: 3, ticksPerTile: 2 }));
tick.run(world); // one tick of the schedule
world.get(unit, Position); // the stored value, or undefined
world.written(ReachedGoal); // messages written on the tick that just ran
  • new World({ resources, devChecks }) creates the world with every resource the systems use. The core adds Tick itself. The schedule refuses to run if a system uses a resource the world doesn’t have.
  • Setup is plain code before the first tick: spawn, insert, remove and despawn act at once, with no command buffer. During ticks, only systems change the world.
  • schedule.run(world) runs one tick. Tick is 0 before the first tick and 1 during it, so world.tick after a run is the tick that just ran.
  • Reading the state: get, has, exists, entities() (ascending id), resource and written. Values come back as they’re stored, so the host can serialize them.

Things that are easy to get wrong:

  • Values aren’t copied. spawn and insert store the object they’re given, so one object passed to two entities is shared. Call the token (or the recipe) once per entity.
  • No state outside the world. A system that writes to a variable outside itself breaks determinism and saving, and nothing catches it. Keep it in a resource.
  • Messages aren’t checked for whole numbers or plain data; only components and resources are.
  • Token names must be unique within a world, and system names within a schedule. They appear in errors and in the state sent to clients.

In the code Tentative

The core is in prototype/sim/src/ecs/. Choices the sections above left open:

  • Storage: each component has a map from id to value and a sorted list of ids. Adding a component to an older entity inserts its id in place, so queries stay in ascending id.
  • singleMut() sits next to single(), as Bevy has single_mut.
  • Development-build checks are a world option, new World({ devChecks }), on by default. Balance sweeps can turn them off. The whole-number check also refuses values that aren’t plain data (a Map, a class instance, a function), since components must be writable as JSON.
  • Inserts and removes on an entity the same buffer spawned need no declaration, for the same reason spawning needs none.
  • Constant resources: Resource<T>(name, { constant: true }), for data that never changes, such as the map’s terrain and deposits. ResMut refuses it, and the development checks walk it once when the world is created instead of after every phase. Walking a 640 × 640 map after every phase made a 3:20 test take seconds.
  • Refused when declared: two Commands parameters in one system, a query that requires and excludes the same component, two phases or two systems with one name, and two different tokens with one name in a world.
  • Lint: the ban on as and any covers the whole sim package and the shared package except the core, which stores every component type side by side and needs casts to hand them back typed. Non-null assertions (!) are banned too.

Porting to Rust

Only relevant if the sim moves to Rust (open-questions.md, #44). Most of the API maps one to one; these parts don’t:

  • Entity ids. Bevy reuses entity indices and doesn’t guarantee iteration order. The port adds a StableId component from a counter, sorts queries by it, and uses it for saving and sync.
  • Messages. Bevy’s Messages<M> lets a reader that runs later in the same frame see new messages. The port uses a small double-buffered resource of its own, swapped by a system at the start of the tick, with reader and writer parameters to match.
  • Declared commands. Bevy can’t check them. The declaration becomes a comment, or the systems involved get an explicit order.
  • Order within a phase. Each phase’s systems use .chain_ignore_deferred(), which fixes the order commands are applied in without adding sync points inside the phase. Bevy’s plain chain() would add them, and later systems would then see earlier systems’ spawns.
  • undefined becomes Option or Result; DeepReadonly<C> becomes &C.