The Three Loops

January 4, 2026 by Farms

So we have a rough relay shape. A dumb little server that collects inputs from players, orders them into ticks, and broadcasts them back out. It knows nothing about the games it serves.

But what actually runs on the client? What does the game code look like? How do we go from "here's a stream of inputs" to "here's a playable game"?

I'm keen to maintain a clear divide between the "input replication", "deterministic simulation" and the final "renderer" that puts pixels on the screen. I'm ultimately building this framework to be used by an AI agent, and, and I believe this seperation will reduce the chances of misunderstandings about which parts of the code to touch.

I'm also keen to strive for a "unidirectional" data flow to make it as easy as possible to reason about.

As a high level design we aim for something like:

Relay

Client

input

input

input

tick

state

pixels

tick

TickLog

Simulation

Renderer

InputLog

Player1

Player2

Player3

...

...

The Three Loops

For some reason in my head this is a split of three loops:

  • Input Loop
  • Simulation Loop
  • Render Loop

Each of these loops can run at independent frequencies, and are isolated enough to run on seperate threads/workers.

The Input loop

The input loop samples inputs from a player, distributes them to all players, and builds a syncronised ordered immutable log of ticks (where a tick is the set of player inputs for a given fixed timestep).

  for {
    input = sample(); // sample the local input state
    broadcast(input); // send the input to players
    {tickNum,inputs} = recvTick(); // get the confirmed set of inputs for tick
    appendToLog(tickNum, inputs); // store local replica of the log
  }

The parts that pass the inputs around and forms the immutable log of ticks is the role of the relay but there still needs to be some client side parts to this flow:

Relay

Client

input

tick

Player1

TickLog

InputLog

Our client library must:

  • Capture local player intent
  • Encode and send them to the relay
  • Replicate and syncronise the input log from all players
  • Expose reading the local log replica

Probably the only interesting bit of design work here is around defining the wire protocols, but feels mostly like wiring. I plan to stick to existing encoding formats (probably CBOR) until I hit some kind of reason to save the bytes.

For transport I really would like to use WebTransport, becuase (1) QUIC streams would make for nice lightweight channels to reduce head-of-line blocking and (2) because it would be available in WebWorker contexts and would allow keeping replication ticking away in it's own thread. Alas Safari is dragging it's feet getting it out there, so looks like it will be a WebRTC based transport polluting my main thread.

The Simulation Loop

This is where the actual game logic lives. Given the current world state and a tick's worth of inputs, compute the next state.

The simulation loop is constantly attempting to apply tick data to the state to move it forward.

for {tickNum,inputs} in tickLog {
  state = simulate(state, tickNum, inputs); // apply inputs to state
}

Every player is rebuilding the state of the world from their own replica of the log. This process must be deterministic or else everyone will have different ideas about what the world state is.

State @ N-1

simulate()

Inputs for tick N

State @ N

The JavaScript environment is full of temptations: Math.random(), Date.now(), Math.sin() that varies across engines, network calls, file system access. An agent writing simulation code might reach for any of these without realising it breaks determinism.

One option would be to try to lock down the JavaScript environment. Sandbox the simulation, remove or stub out problematic globals, provide deterministic alternatives. But this feels a bit whack-a-moley to me.

The other option is to use an environment that's already a blank slate: WebAssembly.

WASM doesn't have Date. It doesn't have Math.random(). It doesn't have network access or file system calls. It's a strict, minimal execution environment by design.

This is exactly what we want. Rather than trying to police what an agent shouldn't use, we give it an environment where the problematic stuff simply doesn't exist.

Technically we could use any language that can compile to wasm for the simulation code, but practically in my experients so far this actually means: Rust.

The tooling and support around compiling Rust to wasm is simply more mature and more practical for this little experiment, so I'll just accept that.

One gotcha is that rust's stdlib float implementation is not always guarenteed to come from the same place depending on the compiler. So we need to ensure we limit ourselves to a consistent math library like libm that provides pure-Rust implementations of math functions to avoid any unexpected non-determinism. This is the same pattern that Rapier uses to gain it's "enhanced determinism" build. I think that will be good enough for now.

The Render Loop

The renderer's job is to take the current game state and draw it to the screen at whatever framerate it likes. The framerate of the renderer will likely be different from the rate at which the simulation updates the state.

for each frame {
  state = getState();
  draw(state);
}

The renderer will be expected to interpolating between changes to the state. The simulation runs at a fixed tick rate - say 30 or 60 ticks per second. The browser wants to render at the display's refresh rate - usually 60Hz, sometimes 120Hz or higher. These don't always line up.

State @ N

Interpolate

State @ N+1

Time since tick

Draw Frame

So the renderer interpolates between tick states to get smooth visuals. If we're halfway between tick 100 and tick 101, we blend the two states together.

React Three Fiber feels like a natural fit here. It's already a unidirectional paradigm - state flows into components, components declare what to render. No imperative "move this sprite 3 pixels" - just "here's where everything is, figure out what changed".

The renderer must be read-only with respect to game state. It can read state, it can't modify it. Visual flourishes like particles, screen shake, sound effects - those are fine, they don't affect the simulation. But if the renderer could change game state, we'd have desync issues again.

R3F's declarative model also makes it hard to accidentally introduce state mutations. Components receive props, they don't reach back and modify some global. That's exactly the constraint we need.

LGTM

So that's the shape of things. Each client runs all three loops. The relay handles input replication. Games are built by writing a Rust simulation and a React renderer, with clear boundaries between them.

Next up I need to actually start building this thing. The relay needs a wire protocol, the simulation needs an API for defining state and handling inputs, the renderer needs hooks for reading state and responding to ticks.