← back to posts

Matrix Rain in the Margins

I compiled my renderer-agnostic Rust matrix-rain library to a 24 KB WebAssembly module and let plain JavaScript paint the falling code in this page's margins.

RustWASMCanvas

Look at the margins on either side of this text. That falling green code isn’t a GIF or a CSS trick; it’s a Matrix-rain simulation written in Rust, compiled to WebAssembly, with the drawing done by a few lines of JavaScript on a <canvas>. This post is about how the two halves meet.

On a phone the margins have no room, so here’s the same effect in a window you can also poke at. Drag the sliders:

Drag the sliders. They retune this window and the margins together.

A library that doesn’t render anything

I have a small Rust crate, matrix-rain, whose entire job is to simulate the effect and nothing else. It’s deliberately renderer-agnostic: the core owns the streams, the glyph changes, and the fade, then hands you a flat list of things to draw. Something else decides how to draw them.

The public surface is tiny:

let mut rain = MatrixRain::new(config)?;
rain.update(delta);         // advance the simulation
for g in rain.glyphs() {    // GlyphInstance { glyph, position, color, role, .. }
    renderer.draw(g);       // ...you draw it however you like
}

The repo ships a macroquad renderer that runs it as a native window or a WebGL canvas. That’s great for a full-screen demo, but dropping a whole GPU framework into a blog just to sprinkle some glyphs in the gutters felt absurd; a macroquad build is megabytes. So I leaned into the “renderer-agnostic” part: ship the simulation, skip the renderer.

24 KB of physics

I added a thin C-ABI binding (no wasm-bindgen, no glue framework) that exposes a handful of functions and lets JS read the results straight out of linear memory:

#[no_mangle]
pub extern "C" fn mr_update(dt_ms: f32) -> u32 {
    let rain = /* ...the global sim... */;
    rain.update(Duration::from_secs_f32(dt_ms / 1000.0));

    BUFFER.clear();
    for g in rain.glyphs() {
        // 7 f32 per glyph: x, y, codepoint, r, g, b, a
        BUFFER.extend_from_slice(&[
            g.position.x, g.position.y, g.glyph as u32 as f32,
            g.color.r as f32, g.color.g as f32, g.color.b as f32, g.color.a as f32,
        ]);
    }
    rain.glyphs().len() as u32   // JS reads BUFFER via mr_buffer_ptr()
}

Built for wasm32-unknown-unknown with opt-level = "z" and LTO, the whole thing is 24 KB. There’s no GPU and no imports: the simulation uses a seeded PRNG and takes the frame time from JS, so it never touches a clock or an entropy source that WebAssembly doesn’t have.

JavaScript does the painting

The renderer is boring on purpose. Each frame it advances the sim, wraps the buffer as a Float32Array over wasm memory, and paints the glyphs with fillText in the site’s JetBrains Mono:

const count = ex.mr_update(dt);
const buf = new Float32Array(ex.memory.buffer, ex.mr_buffer_ptr(), count * 7);

for (let i = 0; i < count; i++) {
  const o = i * 7;
  const x = buf[o];
  if (x >= colLeft && x <= colRight) continue; // keep the reading column clear
  ctx.globalAlpha = buf[o + 6] / 255;           // the fade the sim computed
  ctx.fillText(String.fromCharCode(buf[o + 2]), x, buf[o + 1]);
}

The rest is presentation. The canvas is position: fixed behind everything, so it needs body { isolation: isolate }; without it, a static body’s opaque background paints straight over a z-index: -1 child and you get nothing (I spent a good while there). It only draws where x falls outside the measured reading column, so the text stays clean and only the margins rain. The green is picked per theme, because bright Matrix green is invisible on warm paper.

Why split it this way

Keeping the physics in Rust and the pixels in JS buys two things. The first is reach: the same crate is a native window, a WebGL demo, and this decoration, and none of them know about the others. The second is weight: a <canvas> already exists in every browser, so shipping the effect costs 24 KB of logic instead of a megabyte of framework.

It also makes retuning cheap. The sliders above don’t rebuild anything; they hand new numbers to the running sim through a second export, mr_set_timing, so speed and trail length change mid-fall instead of restarting the field.

Keeping it steady

Getting it to look continuous took more care than getting it to run. My first tuning had every column spawn, fall, and respawn on similar timers, so the whole field pulsed: dense, sparse, dense. The fix isn’t cleverness, it’s variance. A wide spread of stream lengths plus a wide initial stagger keeps the columns out of phase, so at any instant roughly the same number are raining. I picked the numbers by measuring rather than guessing: run the sim headless for a minute, sample the glyph count, and minimise its variation. The steadiest config landed at a coefficient of variation near 0.06, down from wild swings.

The design

The library wasn’t renderer-agnostic at first. It used raylib, then macroquad (picked up for its WebAssembly support), before a refactor separated the simulation from the renderer, so the core produces glyph instances and holds no drawing code.

That separation is what lets one crate back a native window, a WebGL demo, and this page. Here the renderer is a 24 KB wasm module plus a canvas instead of a game framework; the simulation is identical to the one the macroquad build uses.


That’s the whole thing: 24 KB of Rust runs the animation, JavaScript paints it, and the margins fill with falling code. The library never learned what a canvas is; the canvas never learned any physics. Splitting them was the point.

Source: matrix-rain.

Thanks for reading.