2026-09-20 — what porting three of roc-ray's games to the browser taught us about the shape of a Roc program that runs in two places
A week ago the question was how to make a screensaver run on a web page and on a desktop from the same Roc. Today three of roc-ray's own example games — snake, pong, breakout — do the same, and the surprising part is how little it took. This is an attempt to say what the reusable part is, because it does not seem to be specific to games, or to us.
We kept saying "one app, two platforms". There is no such app. Roc requires an
application to name exactly one platform, so snake_web.roc and
snake_native.roc are two different programs. What runs in both places is a
value: SnakeGame.game, of type Game(Model). The game's own modules
import no platform at all — the rules, the board and the drawing depend on
nothing but arithmetic.
So the honest description is one core, two frontends, which is not a
metaphor we invented. It is libretro's architecture: a core exports run and
get_system_av_info — width, height and frame rate — and a frontend owns the
window, the loop, the input and the audio. It is also WASM-4's: a cartridge
exports update, the console owns a gamepad byte and a framebuffer. And in
WebAssembly's own vocabulary our wasm module is a reactor — instantiated
once, its exports called repeatedly, keeping state between calls — rather than
a command that runs to completion.
Seeing it that way answers a question we had been circling. roc-ray feels like
a platform in every sense: it provides effects, it owns the loop, it calls your
init!/update!/render!. Our web platform declares the same three functions
and provides no effects at all — no hosted block, nothing the app may reach
out through. It is a platform in Roc's technical sense and a doorway in the
ordinary one. Neither word is wrong; they are describing different axes.
Both platforms ask for the same triple. Every difference is in the arrows:
# roc-ray asks for # ours asks for
update! : model, Input, Io => model advance : model, Keys, F32 -> model
render! : model, Draw.Frame => {} frame : model -> List(Shape)
render! is handed a surface and returns nothing. frame is handed nothing
and returns a description.
That is the whole of it. A function of the form model, Frame => {} can only
be called by something that can make a Frame — so the caller must be a
platform, underneath you, owning the loop. A function of the form
model -> List(Shape) can be called by anyone, from anywhere, including a
language that has never heard of Roc.
Making the output data is what lets control live outside. Everything else followed from that one decision.
We expected input to be the hard direction. It was free, because Luke had
already made a snapshot a value: Devices.none.with_key_down(KeyW) is how
roc-ray's own tests drive a game. A snapshot is data, so a browser's event loop
can build one, and a test can write one down.
That is why a ported game's read_controls — the single function in each of
these games that touches the keyboard — compiles on both ends with its import
line changed and nothing else. We built Keys, Mouse, Math, Color and
Random to mirror roc-ray's surface under roc-ray's own names, and the ports
cost almost nothing: snake's Snake.roc is verbatim; breakout's Ball,
Bricks and Paddle changed one line each. Only the drawing was rewritten,
because upstream draws imperatively and here a frame is a value.
Adding the mouse later was the test of whether that was luck. It was not: one more argument, no new mechanism, and breakout is now played by pointing at it.
If there is something here for other people, it is these, and none of them is about games.
A frame is a value, not a sequence of calls. This is the load-bearing one.
Input is a value too, and the same value on both ends. Then the function that interprets it is written once.
Mirror the host's names when you are shadowing its concepts — module name,
type name, method name. A port then costs its import lines. But only where it
is unambiguous: we had to call ours Input rather than Devices, because two
nominal types with the same qualified name are ambiguous to the compiler, and
aliasing the module does not help.
Let the thing declare its own rate. A movie paced by the display runs at
one speed on a 60 Hz monitor and another on a 144 Hz one; the same movie on
roc-ray's default ran four times faster again. fps belongs to the program,
and both runners pace to it.
Put the width of a channel in the channel. Our sound indicator counted three tones by hand. Breakout has five, so breaking a brick — the point of the game — lit a speaker with no sound in it, and nothing failed.
That last one is the recurring failure, and it is worth naming on its own.
Three bugs this week had the same shape: two halves of one format kept in step by attention, and nothing between them.
The sound widget guessed a width. The JavaScript frame reader, handed a brush
mode it did not know, computed an undefined span, made its cursor NaN,
compared NaN < length, got false, and dropped the rest of the frame in
silence. And the check we wrote to catch such things could not see colour at
all, because a canvas fill colour is a property assignment rather than a call,
and a gradient's stops went to a stub — so a colour regression was invisible to
the only automated eye we had.
Each was a decoder that disagreed with its encoder. Each passed every check that existed when it was written.
Which points at the real gap. ShapeWire.pack in Roc and shapewire.js in
JavaScript are an encoder and a decoder for one format, written twice by hand.
Roc has a name for what should be doing that: roc glue generates host
bindings from the compiler's own type table, and its README argues the case
better than we would —
platform code should consume generated glue instead of hand-rolling Roc ABI bindings
— and ships ZigGlue.roc, RustGlue.roc and CGlue.roc. There is no
JavaScript one. So we started JsGlue.roc: it reads the type table and emits
readers, and for our platform it already generates the list walk that decodes a
frame — ptr at 0, length at 4, stride 4 — which reads the same 3,820 words our
hand-written decoder does. Tag unions are the gap that matters, since a Shape
is one, and the compiler already hands over the discriminant offset and each
variant's payload layout.
We did not need the ABI to get this far, because we flattened everything to a list of 32-bit words and dodged it. That is why 174 lines of JavaScript suffice — and it is also exactly why nothing could check us.
The browser side is small, and the smallness is not about games. In the same repository: 43 lines of JavaScript run 46 GPU compute kernels, 110 run a UEFI framebuffer with a keyboard controller, 221 run any of these games. Safari is about 120 Roc modules and adds zero JavaScript.
So the size law is roughly: the host code is proportional to the vocabulary of the wire and the surface of the devices, and independent of the complexity of the program. A narrow waist, in the sense the IP hourglass is one.
Which also says where it ends. Sprites and real audio fit — they are still
descriptions, wanting a handle to something the runner loaded. Mouse fits; it
is input. The wall is asynchrony: anything the browser will only give you
as a promise, wanted in the middle of a step. A pure advance cannot await.
Our own survey of roc-ray's two dozen examples says the same thing from the
other side — every one we could not port needed Task, Http, Sqlite or
Udp, and none of them was blocked by graphics.
There are two ways through that wall and the repository has already priced
both. One is hosted effects, which in a browser means a Web Worker, a
synchronous XHR, a SharedArrayBuffer and cross-origin isolation headers. The
other is Elm's: the program answers a list of requests and receives results as
data on a later tick. roc-ray has already built that and named it —
App.Input.messages, filled by Task.spawn!. It keeps the frame pure, it
keeps the determinism that fps exists to protect, and it is the only one of
the two that both runners could implement.
That is the next interesting question, and it is a long way past screensavers.