The Multitap Rube Goldberg Machine
January 7, 2026 by Farms
So we have a schema for state, a schema for inputs, a relay, and three loops that need to run together.
Time to wire it all up.
There's a lot of moving parts here. Schema needs to become code. Rust needs to become WASM. TypeScript needs to know about the same memory layout as Rust. Everything needs to end up in a browser somehow.
It's a bit of a Rube Goldberg machine.
The build
At build time we need to go from source files to a deployable bundle. The interesting bits are around turning the schema into usable code and getting the simulation compiled to WASM.
The schema is the source of truth. From it we generate TypeScript accessors for the renderer and Rust accessors for the simulation. Both generated files describe the exact same memory layout, just in different languages.
The Rust simulation code uses the generated accessors and gets compiled to WASM. The compiled WASM bytes then get embedded into a JavaScript module alongside the schema.
Everything ends up in a standard Vite build output.
All games are sharing a single instance of the relay, so the only thing to deploy is the static html bundle.
A Vite plugin
All of this complexity is hidden behind a Vite plugin. From the game developer's perspective (or the agent's perspective), the entire build pipeline is just an import:
import config from 'multitap:./mt.config.json';
That single import triggers the whole pipeline. The plugin intercepts it, runs codegen, compiles WASM, and returns a module containing everything needed to run the game.
I went with a Vite plugin because I wanted the build complexity completely invisible to the agent. No separate compile steps that would potentially trip up the agent loop.
The plugin uses Vite's virtual module system. Virtual modules let you intercept imports and return dynamically generated code. When something imports multitap:./mt.config.json, the plugin generates JavaScript on the fly containing the compiled schema and base64-encoded WASM bytes.
It's a bit magical. Definitely too magical. But it means an agent has a single build tool for iterating on game.
The runtime
At runtime we split things into our three isolated loops doing different jobs. The main thread handles rendering. A thread for log replica syncing. A thread for the deterministic simulation.
*Note: I actually only have two worker threads currently as WebRTC doesn't work in WebWorkers. Once safari supports WebTransport I will move back to this structure tho
The input worker establishe a DataChannel to the relay server. Player inputs get signed and sent to the relay, which orders them into ticks and broadcasts them back to everyone.
TickMessages are recved by the channel, verified, then assembled into the local replica of the input graph (basically a log of all the ticks we've received).
The simulation thread polls the inputlog, and runs the simulation forward with any new tick. A rollback manager figures out if we need to rewind and replay anything.
State flows one direction: inputs go in, state comes out, renderer draws it.
Inside the simulation worker
The simulation worker is where the actual game logic runs. Here's what happens each time we process ticks:
For each tick we decode the player inputs, copy the current state buffer into WASM linear memory, call the simulation's apply() function which mutates the state in place, then copy the state back out.
The simulation itself is just a single exported function:
#[no_mangle]
pub extern "C" fn apply(state: *mut u8) {
// read inputs from state
// update entities
// write results back to state
}
Everything the simulation needs is in that state buffer. Player inputs, entity positions, game state. It reads, it writes, it returns. Pure function, deterministic execution.
Probably too many moving parts
I won't pretend this isn't a hilarious series of steps for something that could be closely approximated with a websocket connection :D ... There's a lot going on here. Build time codegen, WASM compilation, virtual modules, worker threads, WebRTC channels, rollback networking.
But I got here from following a set of what I think are reasonable decisions and as complex as it is, there's also a certain simplicity to it. Things generally flow in a single direction and that makes me happy.
Whether this actually makes things easier for an AI agent remains to be seen. Time to find out I suppose.