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.

Bundle

dist/

Build

generated.ts

generated.rs

simulation.wasm

Source

mt.config.json

simulation (rust)

renderer (react)

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.

vite-plugin-multitap

load config

Simulation Source

cargo build

compile schema

Rust Bindings

TypeScript Bindings

Export

embed WASM

import config from 'multitap:./mt.config.json'

{ schema, wasmBytes, ... }

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.

Relay

Browser

InputWorker

SimulationWorker

RenderWorker

input

tick

tick

input

tick

tick

tick

tick

tick

state

state

state

ReactRenderer

Rollback

Executor

SimulationWASM

WebRTC Channel

ClientInputLog

WebRTC Channel

ServerSession

ServerInputLog

*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

Decode inputs

Copy state to WASM

simulation.apply()

Copy state out

New ticks

Updated state

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.