Skip to content

Simulation Architecture

How the simulation’s code is organized. The simulation is the heart of this game, so its structure is decided before any game logic is written. Everything here is Tentative until the first spike (the Harvester loop, see Build order) shows the rules work, unless marked otherwise.

The prototype is written in TypeScript (player-and-platform.md), but the design deliberately uses Bevy’s ECS concepts and names so that moving the sim to Rust with Bevy’s ECS later stays mostly mechanical. The background research is in tech-research.md. How the sim talks to clients is in runtime-and-sync.md.

The sim package Decided

  • The sim is its own package with no rendering, networking or user interface code.
  • Time is the tick count. No wall clock anywhere in the rules.
  • Randomness comes from a seeded generator stored in the world.
  • Game quantities are whole numbers: item counts, ticks, progress, positions on the grid. This already matches the whole-tick clocks of the handout rule (base-network-and-inventory.md).
  • Loops run in a defined order, by sorted id or by whatever the design specifies.
  • It runs with no screen, for tests and balance sweeps, in the same code the game uses.

Shared, sim and client packages Decided

The client and the server are separated by a hard wall. The design treats them as two programs that share only a small package of pure code. Single player is a way of running the server locally, not a reason to join the two.

  • Shared holds what both sides need and nothing else: pure functions and plain data shapes. That covers the client-side checks (canPlace, the Scout’s range), fog math (fogAt), map queries, storage rules, which buildings touch, recipes and balance numbers, seconds and ticks, and the protocol: the shapes of commands, state updates and messages. It has no ECS, no components, no systems and no world.
  • Sim is the server core: the ECS core, components, resources, systems, the schedule, map generation and the opening. It depends on shared. Its components wrap shared data shapes rather than define their own.
  • Client depends on shared only. It knows the protocol and nothing else about the server.
  • Single player runs the sim in a Web Worker, built as its own bundle from a small host package that depends on the sim. The client starts it from a script address, the way it would connect to a server’s address, and talks to it only through the protocol. A multiplayer client never loads that bundle, so it carries none of the sim’s code, memory or compute.
  • The wall is the dependency graph. The client’s package doesn’t list the sim, so it can’t import it. A lint rule and a check that the client bundle holds no sim code back that up.
  • A feature can span both packages. The placement check is in shared and the system that applies placements is in the sim. Files for the same feature take the same name in each package, so pairs are easy to find.
  • In the prototype Decided: prototype/shared is @factory/shared. The ECS core takes its Entity and DeepReadonly types from it. Shared storage, map and fog code only reads: adding and moving stacks, building the map and revealing tiles stay in the sim, because the client never changes state. Lint rules stop shared importing the sim, the sim reaching into shared by a relative path, and client code importing the sim.
  • If the sim moves to Rust (player-and-platform.md), shared is the part both sides still need. It would become a Rust crate compiled to WebAssembly for the client, or stay TypeScript and be kept in step by tests.

Rejected: the client importing the shared functions from the sim package (nothing stops it importing a system or the world), and one sim package with separate shared and host entry points (the client still depends on the sim, so the wall is a lint rule rather than the dependency graph).

Seconds and ticks Decided

Recipes, speeds and delays are written in seconds and converted to whole ticks when the game loads (tier0-proposals.md).

  • Round to the nearest tick, never up or down. Floating-point math lands just off whole numbers: the Silo’s 0.3 s × 67 × 20 comes out as 401.99999999999994, so rounding down would give 401 ticks instead of 402.
  • Round a total once, not each unit. A build time is 0.3 s per unit of material, converted after multiplying; the Scout’s recharge, 4 s per 100 tiles, only comes out whole as a total.
  • Development builds warn when rounding changes a value by more than 1%, so a number that doesn’t fit the tick is changed in the design rather than silently in the game. The first case is Tier 1’s truck: 3 tiles/s is 6.67 ticks per tile, which rounds to 7 (2.86 tiles/s).

Everything in the first test is already a whole number of ticks. If a rounded speed ever feels wrong, a unit can step on an uneven rhythm instead (3 tiles every 20 ticks as 7, 7 and 6), which is exact on average.

Same world plus same commands gives the same next world. Because the host owns the tick, clients can’t desync whatever happens; determinism is for tests, replays and moving a single-player world onto a server.

Entities, components, systems

The sim uses the entity-component-system pattern, not a class hierarchy.

  • An entity is just an id.
  • A component is plain data attached to an entity, with no logic. Components can be added and removed at any time; an entity’s “type” is whatever set of components it has right now.
  • A system is a function that runs every tick over all entities that have the components it asks for (a query), with optional filters such as “with Deployed” or “without Asleep”.
  • A resource (Bevy’s term) is global data that isn’t attached to an entity: the tick count, the random generator, the recipe table.

Systems never call each other. They cooperate through component data: a Harvester’s brain system writes where it wants to go, and the movement system, which knows nothing about Harvesters, moves it. Shared systems know nothing about specific ones; specific systems use shared components as tools.

Component naming

KindConventionExamples
Data the entity hasPlain nounPosition, Health, Storage, Mover
What kind of thing it isThe thing’s name, as a marker with no dataCrashedShip, Depot, Harvester
Settings that switch on a behavior, set by the recipe…Behavior or …SettingsFleeBehavior { hpThreshold, safeSpot }, HarvesterSettings
A state it’s in now, often with no dataAdjective or past participleDeployed, Fleeing, Asleep
A unit’s decision state, written by its brain system…BrainHarvesterBrain, CrewBrain
Counters toward something, in ticks…ProgressHarvesterProgress { collect, unload }
A request for another system to act on…IntentMissionIntent, MoveIntents
SystemsVerb phrase, with a plural object when it loops over manymoveUnits, collectCargo, handOutStock
Systems that apply a player’s commandapply…CommandsapplyScoutCommands, applyPlaceGhostCommands
Brain systemsupdate…BrainsupdateHarvesterBrains, updateCrewBrains
Arbiter systemsarbitrate…arbitrateMovement
ResourcesPlain noun for what it holdsTick, WorldMap, PlayerCommands
Messages: something happenedPast participleUnloaded, Damaged, Destroyed
Messages: a reply…ResultCommandResult
  • A kind marker takes the thing’s plain name, so data about that kind needs another name (DepotSettings, not a Depot with fields). Filter by kind (With(Harvester)) rather than by a component that happens to come with it.
  • When a state and an event would share a name, the event keeps the plain past participle and the state gets an adjective: an Arrived message, an AtDock state.

Events aren’t components; see States and events.

Recipes

Each kind of thing in the game is a recipe: a list of components with starting values. The recipes form one table, so the balance numbers live in one place and a change in the design docs is a one-line change.

RecipeComponents (illustrative)
HarvesterHarvester, Position, Mover (20 ticks per tile = 1 tile/s), Storage (1 slot), owner building, HarvesterBrain
DepotPosition, Footprint, Storage (6 slots), UnitSlots (3)
Storage SiloPosition, Footprint, Storage (about 24 slots), SiloFilter

Numbers from balance-and-simulations.md, base-network-and-inventory.md and tier0-proposals.md.

Storage is one component for everything

Vehicles and buildings use the same Storage component, following the stack rules in base-network-and-inventory.md: a fixed number of slots, each empty or holding one stack of one resource, with a stack size per resource (ore = 50). Each stack records when it arrived, so buildings can move out the oldest stack first. A few shared functions (can this take more of an item, add with overflow, take the oldest) hold the rules once, and unloading, truck loading, crew restocking and the handout all use them. “Full” means no slot can take more of the item in hand, which lets partial stacks stuck in truck slots show up naturally.

The design docs use “slots” for two things. In code they’re separate components: Storage (stacks) and UnitSlots (vehicles a Depot or Barracks holds).

The schedule

Each tick runs an ordered list of phases, and each phase runs a list of systems. The whole order lives in one file, so reading it tells you everything that happens in a tick. Bevy calls these ordered system sets in a schedule.

PhaseSystems (examples)Writes
Commandsapply players’ commandswhatever each command changes
Senseapply damage, detect threatsHealth, RecentDamage, ThreatNearby
DecideHarvester brain, crew brain, truck routes, fleeeach its own intent and brain state
Arbitratemovement arbiterMover.goal
Actmove units, hand out stock, advance crafting, build ghostspaths, positions, input buffers, progress
Settleplace surplus, unload cargo, update noisestorage, noise

There’s no Cleanup phase: old events are cleared by the ECS core at the start of each tick (ecs-core.md).

Your design’s rule that an item made on tick N can only be used on tick N+1 (base-network-and-inventory.md) is a property of this order: the handout runs before crafting, and finished items land in output buffers.

Rules for writing data

Ordinary writes are fine: a system changing Health or Position in place is normal. Three cases cause trouble, and each has one rule.

  1. Structural changes go through a command buffer. Adding or removing components, and creating or destroying entities, while a system loops over a query makes it skip or repeat entities. Systems record these changes in a buffer instead, and the scheduler applies them at the end of each phase (a sync point). Bevy calls the buffer Commands; Unity calls it an entity command buffer.
  2. Inside a phase, no two systems write the same component, and no system reads what another system in that phase writes. Data moves forward from phase to phase, never sideways within one. Then the order of systems inside a phase doesn’t matter.
  3. Order inside a system’s own loop is a game rule. In the handout, whoever is served first gets the stock, so the loop follows the design’s order (earliest due tick first). Everything else loops by sorted id.

One writer per field

Every field has one system that writes it. When something goes wrong, you know which system to look in. When several behaviors want to control the same thing, they don’t share the field. Each writes its own intent, and one arbiter system picks the winner and writes the field.

Movement is the main case. Mover holds settings from the recipe (ticks per tile), a goal, the current path and a status (idle, moving, arrived, no path).

  • Brains write intents, never goals or paths. The Harvester brain writes “my mission wants me at the patch”. A flee system, if a unit has one, writes “get me to safety, urgency 80”.
  • The arbiter is the only writer of Mover.goal. It picks the highest-priority intent. Each behavior turns its own reasons (damage taken, attacker size) into a number, so the arbiter knows the policy (what overrides what) but not the reasons. If changing how fleeing works means editing the arbiter, move that judgment into the flee system.
  • The movement system owns pathfinding and stepping. It reads the goal, finds a path, steps along it, and sets the status.
  • Priorities live in one table, so “flee beats recall beats mission” is readable and tunable in one place. With two or three intents, separate intent components are clearest. Past that, a single MoveIntents component with one entry per behavior keeps the arbiter generic.
  • Check that you arrived where you asked to go, not just that you arrived. A Harvester that fled has arrived somewhere, but not at its patch.

Behaviors are opt-in by component. A Sword Bot without FleeBehavior is never seen by the flee system, and the arbiter never finds a flee intent on it. Fleeing itself is only an example here; it isn’t in the design yet.

States and events

StateEvent
MeaningSomething true for a whileSomething that happened, once
LastsUntil a system changes or removes itOne tick after it’s produced
ExamplesDeployed, Fleeing, Health, RecentDamagetook damage, arrived, unloaded, destroyed
Stored asA componentA message in a queue (Bevy calls these Messages)
  • An event is a message, not memory. If something must be remembered, the system that reads the event turns it into state.
  • Events produced on tick T are read on tick T+1 and cleared at the end of T+1. Then system order never decides whether a reaction sees an event. It’s the same principle as no same-tick chaining.
  • The same event queues feed the client. “Unloaded” becomes a “+50 Basalite” popup and “destroyed” an explosion sound, filtered by what each player can see.

Example: an alien hits a Harvester on tick T and appends a damage event. On T+1 the health system lowers Health (its only writer) and updates RecentDamage. Any reaction, such as a recall, is decided from that state. At the end of T+1 the event is gone, but its effects remain.

Checking the rules in code

The conventions are enforced, not remembered.

  • Each system declares what it reads and writes. The scheduler refuses to start if two systems in one phase write the same component, or if one reads what another writes in that phase, and names the systems that clash. The check runs in every build and understands “with” and “without” filters, like Bevy’s ambiguity detection (which is off by default). Details in ecs-core.md.
  • Structural changes outside the command buffer can’t be written: a system gets no access to the world except through its declared parameters.
  • Who writes each component is listed and tested Tentative. Each component’s doc comment names the system that writes each field, or says the recipe sets it and it never changes. prototype/sim/test/writers.test.ts keeps the same list per component and fails when the schedule’s write declarations differ, so a new writer has to be added on purpose. The core sees writes per component, not per field, so per-field ownership is kept by the comments.
  • Switches over states are exhaustive, so adding a new brain state that some system doesn’t handle is a compile error.

Sleeping and distant areas

  • Idle things sleep. A machine with nothing to do, such as one waiting for Ferrite that isn’t coming, gets an Asleep marker, and per-tick systems skip it. Each network keeps a short list per resource of who is waiting for it; when that resource lands in the network’s storage, the system that placed it wakes them. Sleeping things don’t change, so they also never need to be sent to clients.
  • Distant, quiet areas can be simulated as summaries Open. A hive no player is near might be tracked as numbers (aliens, growth) instead of individual aliens walking around, until something comes close.

Between sleeping, summaries and network inventories instead of belts, the work each tick goes into the small active fraction of the world.

ECS core Tentative

The game uses its own small ECS core, modeled one-to-one on Bevy’s, not a library. It provides entities, components as plain objects, resources, queries with “with” and “without” filters, a phase schedule, a command buffer applied at sync points, message queues, and the access checks above, all under Bevy’s names.

  • Why not a library: the TypeScript libraries mostly supply storage and queries, the easy part. None has Bevy’s deferred commands or message queues, and only Becsy enforces access, with a different ordering model. Writing the core keeps Bevy’s semantics exact (which keeps a Rust port mechanical), keeps state as plain objects (so sending the whole state is just serializing it), keeps loop order under the game’s control, and avoids a dependency that can go stale.
  • Libraries considered: Miniplex (no release since 2023), Koota (the strongest alternative: active, with change tracking and relationships), bitECS 0.4 and Becsy. The comparison is in tech-research.md.
  • Later, if needed: change tracking (useful for sync diffs) and relationships between entities.
  • The API is in ecs-core.md. Bevy reads what a system accesses from its parameter types, which TypeScript can’t see at run time, so each system declares its parameters as an object of specs that serves as both the access declaration and the source of its argument types Decided.

Build order Tentative

  1. Sim only, no screen. The game’s own ECS core with its schedule, command buffer, event queues and checks, with tests. Then the Crashed Ship, one Harvester, a Basalite patch, Depot storage and an Engineering Crew building the first Depot. Tests check the timings in balance-and-simulations.md, such as the first Depot at about 3:20.
  2. The plain client. The host in a Web Worker, colored squares with letters, panels and status lights, and placing a ghost by tapping.
  3. The netting and handout rule, with the Python cases ported as tests.
  4. The rest of Tier 0, then noise and aliens.

Steps 1 and 2 are the first test, which goes up to the first Depot.

The Python sims become the tests. scripts/sim/ holds the fair-share cases, the random-factory sweep, the Tier 0 checks and the Tier 1 pacing run. They’re ported to TypeScript tests by hand Decided; the test suite runs no Python. When the game’s sim gives the same results, it behaves as designed.

Worked example: one Harvester trip home

Brain systems run before movement.

TickHarvester brainMovement
5000Storage can’t take more Basalite, so it writes a mission intent for the Depot and sets its state to heading home(the arbiter turns the intent into a goal) Finds a 12-tile path and sets status to moving
5001–5239Status is still moving, so nothingOne step every 20 ticks
5240NothingLast step, status arrived
5241Arrived at its own destination, so it starts unloadingNothing

The brain sees the arrival one tick after it happens, which keeps every tick predictable however many systems are added.