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
| Bevy | Here | Notes |
|---|---|---|
#[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>, Entity | T, Mut(T), Opt(T), Has(T), Entity | |
With<T>, Without<T> | With(T), Without(T) | |
Res<T>, ResMut<T> | Res(T), ResMut(T) | |
Commands | Commands({ inserts, removes }) | Declares what it adds and removes |
MessageReader<M>, MessageWriter<M> | MessageReader(M), MessageWriter(M) | |
System sets chained in FixedUpdate | Schedule([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
&mutworks. Bevy’sstruct Health(i32)becomesHealth<{ 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. Entityis 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);
readsandwriteslists 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 Worldparameter). 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
| Term | Item | Access |
|---|---|---|
C | DeepReadonly<C> | Reads C; requires C |
Mut(C) | C | Writes C; requires C |
Opt(C), Opt(Mut(C)) | the value, or undefined | Reads or writes C; doesn’t require it |
Has(C) | boolean | None |
Entity | the id | None |
With(C), Without(C) | (filter) | None |
- Items are tuples in spec order:
for (const [pos, mover] of movers.iterMut()), like Bevy’sfor (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 forMutterms, the same split as Bevy.Or,AddedandChangedare 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)andgetMut(id)return the item, orundefinedif the entity doesn’t exist or doesn’t match the query Decided. Bevy returns aResult; the port turnsif (!x)intolet Ok(x) = q.get(e) else {…}.single()returns the item if exactly one entity matches, otherwiseundefinedTentative. 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 hasiter().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) andResMut(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
Resnever comes back empty andOption<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 oncecommands.entity(id).insert(Asleep()); // declared in insertscommands.entity(id).remove(Deployed); // declared in removescommands.entity(id).despawn();spawnreturnsEntityCommands, 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:
insertandremovethrow, because that’s a bug; despawning an entity that’s already gone does nothing;tryInsertandtryRemoveexist 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.MessageMutatoris 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_systemdoes inFirst. 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
MessageWritertakes write access toMessages<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:
- The core adds one to
Tick. - The message buffers swap.
- Each phase runs its systems in list order, then applies their commands.
- 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 X | Writes X (Mut, ResMut) | Inserts or removes X | |
|---|---|---|---|
| Reads X | fine | clash | fine |
| Writes X | clash | clash | fine |
| Inserts or removes X | fine | fine | clash |
- It’s filter-aware, like Bevy’s. Two accesses don’t clash if one requires a component the other excludes.
C,Mut(C)andWith(C)require C;Without(C)excludes it. WithoutOrthis 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
NaNandInfinity. Considered: a brandedInttype, 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
Commandsdidn’t declare throws.
Left out
| Bevy feature | Instead, for now | Add when |
|---|---|---|
Change detection (Added, Changed, Ref) | The whole state is sent each tick | Sync 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 it | Many-to-many links or cascading despawns become common |
Run conditions (run_if), Single/Populated skipping | The system returns early | — |
States (OnEnter/OnExit) | Game state lives in components | — |
Required components (#[require]) | Recipes list every component | Recipes start repeating themselves |
Or, ParamSet, Local, exclusive systems, MessageMutator, Option<Res> | See the sections above | — |
| Ordering within a phase, ambiguity exemptions | List order; move systems between phases | A real case appears |
Several schedules, Startup, fixed time | One tick schedule; setup is a plain function; the tick count is the clock | — |
| Running systems in parallel | Single-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 scheduleworld.get(unit, Position); // the stored value, or undefinedworld.written(ReachedGoal); // messages written on the tick that just rannew World({ resources, devChecks })creates the world with every resource the systems use. The core addsTickitself. 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,removeanddespawnact at once, with no command buffer. During ticks, only systems change the world. schedule.run(world)runs one tick.Tickis 0 before the first tick and 1 during it, soworld.tickafter a run is the tick that just ran.- Reading the state:
get,has,exists,entities()(ascending id),resourceandwritten. Values come back as they’re stored, so the host can serialize them.
Things that are easy to get wrong:
- Values aren’t copied.
spawnandinsertstore 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 tosingle(), as Bevy hassingle_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 (aMap, 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.ResMutrefuses 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
asandanycovers 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
StableIdcomponent 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 plainchain()would add them, and later systems would then see earlier systems’ spawns. undefinedbecomesOptionorResult;DeepReadonly<C>becomes&C.