Skip to content

Runtime, Commands, and Sync

How the game runs while people play it: the simulation tick, the client’s frames, the commands players send, and the state the host sends back. The server-owned tick itself is decided in player-and-platform.md. How the simulation’s code is organized is in simulation-architecture.md.

Three loops Decided

The game is three separate loops that talk through narrow channels.

LoopRateJob
Simulation (the host)Fixed, 20 ticks per secondThe rules of the world. The only place game state changes. Knows nothing about screens, touch or networks.
CommandsWhenever a player actsSmall, serializable requests (“place a Depot at 12,7”, “give Depot 3 a route to Outpost B”). The only way a player changes anything.
ClientAs fast as the screen draws, about 60 frames per secondCamera, panels, overlays, sprites and status lights. Reads a copy of the state it’s sent. Never changes game state itself.

Information goes around one way: input becomes a command, the host applies it on a tick, and the resulting state goes back to the client to draw. In single player the host runs in a Web Worker and the channel is a message between threads; in multiplayer it’s the network. Nothing else differs.

Ticks and frames Tentative

  • The sim ticks at a fixed rate no matter how fast the device draws. A slow phone drops frames, never ticks. If the host falls behind, it runs several ticks in a row to catch up.
  • Smoothing Tentative: the renderer draws about one state behind and blends units from where they were drawn to where the latest state has them, so they glide (first-test.md). Units move a whole tile every few ticks, so blending ticks alone isn’t enough: each unit’s view also says how far into its current step it is. That’s only for show; the sim still moves whole tiles.

Commands Tentative

  • Only players send commands, and commands go to buildings, not units (see player-and-platform.md). What a unit wants to do, like a Harvester heading home because it’s full, is decided inside the sim and never crosses the boundary.
  • Each command is a small typed record with a sequence number per player. Example kinds: place a ghost, cancel a ghost, demolish, set a mission, switch a building on or off, set loading rules.
  • Commands wait for the next tick boundary and are applied at its start, in a fixed order (arrival order). Every system in a tick sees the same world.
  • The host checks every command and either applies it or rejects it with a reason. Each state message lists which commands were applied or rejected.

Two players acting “at the same time” never conflict: their commands are two entries in one queue, applied in order on the same tick.

Player interaction Decided

  • The player’s own action shows at once, the normal way: the ghost appears, the switch flips, a click plays. There’s no pending state, no dimming and no spinner. The client assumes the command will succeed, because it nearly always does.
  • The world’s reaction arrives with the next state update, about 100–200 ms later in multiplayer: the crew drives out, the status light goes off, the counter changes. That reads as the world responding, not as lag.
  • A rejection is rare and gets undone with a short reason. It only happens in races, such as a co-op partner placing something on the same tile on the same tick.
  • The only visible waiting state is a single “Reconnecting…” banner when the connection drops, never a per-action indicator.
  • Some state never becomes a command at all: the camera, zoom, the current selection, the open panel and which overlay is on. Opening a building’s panel costs the sim nothing.

Checking commands on the client Tentative

  • The checks are pure functions shared by the client and the sim, such as canPlace(view, command). They live in the shared package, which both sides depend on; the client never depends on the sim (simulation-architecture.md). The client runs them on its copy of the state to grey out buttons and turn a footprint red before anything is sent. The host runs the same functions again and has the final say.
  • What the client knows comes from state it already has (terrain, occupied tiles, fog, unlocked tech, stock totals). The host doesn’t send a list of available actions.
  • Where a check depends on something the client isn’t sent, such as a fogged tile, the client’s answer is a best guess and the host’s wins.
  • Missing Basalite never blocks placing a building. Ghosts are free and resources are spent at build time (resources-and-economy.md), so the ghost is placed and shows “waiting for materials”. Placement is blocked by an occupied tile, fog, wrong terrain or a building that isn’t unlocked. The build menu can show a shortfall (“short 40 Basalite”) in red while still allowing the ghost.

Latency Tentative

DelaySingle playerMultiplayer
Waiting for the next tick0–50 ms0–50 ms
Network round tripnoneabout 50–150 ms
Drawing one tick behindabout 50 msabout 50 ms
Total, tap to the world’s reactionabout 50–100 msabout 125–250 ms

That’s fine for this game: with no avatar and missions given to buildings, nothing the player does should cause motion right away, and the instant feedback above covers the gap.

Sync in the prototype Tentative

Kept deliberately simple. The structure is what matters early; the efficiency comes later behind the same interface.

  • The host sends the whole state every tick, as plain JSON. In single player that’s a message from the worker to the page. For multiplayer tests, assume everyone is on fast home internet.
  • “The whole state” means what’s on the map, not the map. Terrain never changes, so it’s sent once at the start. Each tick then carries the entities (units, buildings, ghosts, foundation) and the tiles newly explored. What’s visible right now isn’t sent: the client works it out from unit positions and vision ranges, with a function from the shared package.
  • Messages are plain objects that can be written as JSON, with no class instances, so the worker’s messages and a WebSocket’s are the same.
  • Each message carries the tick number, the command results (applied or rejected, by sequence number), the state, and the tick’s events (for sounds, popups and advisor lines). When the host runs several ticks at once, at high speed or catching up, one message covers them all: every tick’s results, events and explored tiles, and the state after the last (first-test.md).
  • The host builds each player’s message with a “view for this player” function. In the prototype it returns everything. When fog filtering matters, only that function changes.
  • No server framework. A plain WebSocket server in Node is enough for a few friends. Colyseus was considered; its main value is diffing, rooms and reconnection, and it can’t host single player inside a browser worker.

Sync later Tentative

Each step is an optimization behind the same boundary, not a redesign. The likely order:

  1. Smoothing on the client (above).

  2. Diffs: send only what was added, changed or removed since the last tick. Prefer marking fields as changed when they’re written (dirty flags) over comparing whole snapshots, because most buildings sit still.

  3. Plans and rates instead of per-tick values. A few field types, each turned into a value on the client by one shared function, valueAt(field, tick):

    Field typeHoldsUsed for
    Linearvalue, change per tick, as-of tickNetwork totals (“130 Basalite, +42/min”), anything filling or draining steadily
    Timedstart tick, end tickCrafting, building, repairs, deploying
    Pathpoints, departure tick, arrival tickTrucks, Harvesters, crews, drones

    The host sends one message when a plan changes and nothing in between. Values that really change every tick, such as inventories during the handout, are sent as a smoothed display value at most every so often. Plans can go stale (storage fills and the rate stops); the host sends the new plan and the client jumps or blends to it. A client’s projection is only ever for display, never for a decision.

  4. Filtering per player: only what each player can see (fog), plus detailed data only for what they’re looking at. Opening a Depot’s panel tells the host to include its stacks until the panel closes.

  5. Snapshot on join or reconnect, then changes. A gap in tick numbers means ask for a fresh snapshot. A join gets what the player can see now plus what they remember of explored areas.

  6. Binary encoding, as the very last step: MessagePack first (a drop-in for JSON), then a custom encoding built on the field types above if needed. JSON stays while the sync layer is young, because every message can be read in the browser’s developer tools.

All of this lives in one generic replication layer. Gameplay code never builds messages.

Worked example: two players on one tick

An illustration of the messages, not a spec. Player A switches off a machine that uses 12 Basalite a minute (a made-up machine). Player B drags out 10 Stone Wall tiles (3 Basalite each, 30 in all). Harvesters bring in 42 a minute.

  1. Tick 6000. Both clients have Basalite: 160, +30/min, as of tick 6000 and count up locally.

  2. About tick 6010, both act. A’s switch flips at once and sends { seq: 41, toggle, id: 150, on: false }. B’s 10 ghosts appear at once and send { seq: 88, place, StoneWall, tiles… }. B’s client doesn’t subtract 30 Basalite: whether and when a crew loads it is the host’s call.

  3. Tick 6014, the host applies both. Machine 150 switches off and stops asking for Basalite. Ten ghosts are created. An idle Engineering Crew takes the job and loads 30 Basalite, so the network drops to 130. The net rate is now +42/min.

  4. The host sends each player the same update, except for the command results:

    {
    "tick": 6014,
    "acks": [ { "seq": 41, "result": "ok" } ],
    "added": [ { "id": 400, "type": "StoneWall", "x": 40, "y": 12, "state": "ghost" } ],
    "changed": [
    { "id": 150, "on": false, "status": "off" },
    { "network": 1, "Basalite": { "amount": 130, "perMin": 42, "asOfTick": 6014 } },
    { "id": 77, "mission": { "type": "build", "targets": [400] },
    "carrying": { "Basalite": 30 },
    "path": [[22,9], [31,10], [40,12]], "departTick": 6014, "arriveTick": 6094 }
    ],
    "events": [ { "type": "switchedOff", "id": 150 }, { "type": "crewDispatched", "unit": 77 } ]
    }

    (Shown in the later diff-and-plan form; the prototype sends the whole state instead.)

  5. Each player sees, about 150 ms after acting, the other player’s change appear, the counter jump down to 130 and then rise faster, and the crew leave along its path. For those 150 ms both clients counted up at the old rate, an error of about 0.1 Basalite.

  6. Afterwards the host is mostly quiet. The crew arrives at tick 6094, each wall tile gets a build start and end (1 s each, from tier0-proposals.md), and the crew gets a path home when it’s done.

If no crew is free, the ghosts are placed and Basalite stays at 160 until one is. That’s why the client must never subtract the cost itself.

Open

  • When does a crew’s Basalite leave the network: when it loads at home (logistics.md, “draw materials from their home network”) or when it builds (resources-and-economy.md, “paid when the crew actually builds it”)? The example above assumes loading. See open-questions.md.
  • When to add diffing and binary encoding: when measured, not before.