· 6 min read
Sixty kilobytes and a window
Writing Tetris in Grain and wrapping it in Deno's new desktop command revives the Flash-era shape of a game: a tiny compiled artifact you can play in the page or download and run in a native window.

The games I remember from school computer rooms were sixty-kilobyte files. Somebody found them, somebody passed them around, and they ran anywhere a browser with the Flash plugin existed, which in 2004 meant everywhere. When the plugin died we told ourselves the open web had won. The shape disappeared anyway: the small, self-contained, playable artifact that traveled as a file.
Yesterday I wrote about Grain, a small functional language that compiles to WebAssembly, and ended with the compiler as a deployment detail. This is the follow-up where I stop treating it as a detail. I wrote Tetris in Grain, wrapped it in Deno's three-week-old deno desktop command, and got the Flash shape back at both of its old sizes: a file you can embed in a page — it is playable at the end of this article — and a double-clickable thing with its own window.
Three hundred lines of falling blocks#
The game logic is 300 lines of Grain and contains no interop, no annotations for the host, nothing that knows a browser exists. It is arrays, while loops, and arithmetic — the board is a flat array of 200 numbers, the seven pieces are tables of offsets, and the public surface is a handful of exported functions:
provide let tick = () => { if (overVal == 0) { if (collides(pieceKind, pieceRot, pieceX, pieceY + 1)) { lockPiece() } else { pieceY += 1 } }}provide let move = (dx: Number) => { if (overVal == 0 && !collides(pieceKind, pieceRot, pieceX + dx, pieceY)) { pieceX += dx }}I want to note, because it still surprises me, that this file compiled on the first try, and the headless test harness played a full stacked-out game before I had ever seen the board drawn. Grain's willingness to let you write a mutable, index-happy game loop without ceremony was the thesis of the last essay, and a 7-bag shuffle over a mutable array is where that thesis earns its keep.
The interop is where I expected to spend a bad afternoon, because Grain values are not machine integers — everything is tagged so the runtime can tell a small number from a pointer. My first call from JavaScript panicked the module with an unreachable. The fix turns out to be two bit operations and one stubbed system call:
const { instance } = await WebAssembly.instantiate(bytes, { wasi_snapshot_preview1: { fd_write: () => 0 },});instance.exports._start();// Grain's simple numbers are 31-bit ints tagged in the low bit.const tag = (n) => (n << 1) | 1;const untag = (v) => v >> 1;const call = (name, ...args) => untag(instance.exports[name](...args.map(tag)));That is the whole boundary. Shift in, shift out, ignore fd_write because nothing prints in production. I have written JSON serializers with more ceremony than this ABI, and I keep thinking about how unreasonable that is: a garbage-collected functional language hands me a .wasm file, and the glue fits on a napkin because both sides agreed on nothing more than 32-bit integers.
The napkin does constrain the API. Everything crossing the boundary is a small integer, so the renderer asks for the board one cell(x, y) at a time — but Tetris state is nothing but small integers anyway.
The window is one function#
deno desktop landed in Deno 2.9 on June 25 and is marked experimental. Its entire programming model is: write a Deno.serve() handler, and the compiled binary opens a native webview pointed at it. My whole desktop layer is twenty lines:
const dir = import.meta.dirname!;const html = await Deno.readFile(`${dir}/index.html`);const wasm = await Deno.readFile(`${dir}/tetris.wasm`);Deno.serve((req) => { const { pathname } = new URL(req.url); if (pathname === "/tetris.wasm") { return new Response(wasm, { headers: { "content-type": "application/wasm" }, }); } return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" }, });});One deno desktop --output grain-tetris main.ts later I had a real window on my desk with falling tetrominoes in it. Then I looked at what the build had left on disk, and the sizes came out wonderfully lopsided:
| the whole game, zipped | 61 KB |
|---|---|
| tetris.wasm, release build | 141 KB |
| launcher binary | 301 KB |
| bundled runtime + webview | 86 MB |
That last bar is not a rendering bug. The shared library sitting next to the launcher — the bundled runtime and webview shim — really is 86 megabytes, more than double the "roughly 40 MB" the docs cite, and I checked the number three times before I believed it. The cartridge is tiny; the console it ships with is not.
That ratio is the one honest difference from the thing I am nostalgic for. The Flash player was a one-megabyte download that every machine already had, so a game only ever cost you its own sixty kilobytes. deno desktop ships the player with every cartridge. I do not think that kills the shape — disks are patient, and the browser path costs nothing extra — but I notice nobody is going to email a friend an 86 MB Tetris, and in 2004 that was the entire distribution model.
Flash, but the good parts#
Here is why this feels like Flash coming back rather than a party trick. The artifact is small and inert — a .wasm file can no more read your disk than a .swf could draw outside its rectangle, and this time the sandbox is in the spec instead of in Adobe's patch schedule. The runtime is anywhere: the exact same 141 KB module is running in this article and inside the native window, byte for byte.
And the language on top is whatever you like — Grain today; Rust, Zig, or OCaml if that is your house dialect — where Flash gave you ActionScript or nothing. As far as I can tell there is no technical reason every small game and demo of the next decade cannot ship this way. The missing piece is the passing-around itself, and I cannot compile that.
So here it is. The module below is the same tetris.wasm the desktop app embeds, fetched by this page and driven by the same two-bit-shift ABI. Click it, and the arrow keys stop scrolling and start steering.
My high score so far is 3,406, set while pretending to proofread this paragraph. Purists will notice the naive wall kicks within a minute, and the piece statistics panel Game Boy owners remember is missing because I ran out of Saturday.
The zip also carries tetris.gr itself, all 300 lines, kicks and shortcuts on display. If the last essay made you curious about Grain, the source is the better second read: it meets you halfway.