The JavaScript Backend Runs a Game: Ponkatris in the Browser

· 6 min read

Ponkatris is live on this site: a marble drops onto a pink X, bounces, and you Boost it back up through the pockets to take four diamonds. The game is 451 lines of pure .k — stores, queries, kernel physics — and the file you are playing was emitted by koruc --lang=js. The same source builds a native Raylib binary. Nothing was ported; the language grew a second back end far enough to carry a game.

This is not WASM. The compiler’s browser target is JavaScript, and the honest story is better than the acronym: a game is the first real workload the JS lane has ever run, and it earned its keep the way koru-libs workloads are supposed to — every wall it hit became a compiler fix.

Both targets, one program

That last sentence of the intro is the deliverable this post exists to announce, so it gets its own paragraph: Ponkatris is the first real program that ships on both emitters from one source. koruc main.k produces the native binary — a Raylib window, 60fps, the kernel solving contacts in Zig. koruc main.k --lang=js produces the script running at /ponkatris — a Canvas2D surface, the same kernel solving the same contacts in JavaScript. Same main.k, same sim, same rubicon.k level data, same HUD format string. The only file that differs is the host facet, and that is the entire point of facets.

The harness has been dual-lane all along — a regression test’s LANGUAGES file says zig js and the same expected.txt must come out of both emitters — but until now both lanes answered “hello” and arithmetic. A game is the first time the agreement is about something: gravity integrating identically, collisions resolving on the same frame, the HUD’s {{ t:d:.4 }} printing the same four decimals under both backends.

Worth pausing on what crossed over. The physics in this game is std/kernel — Koru standard-library code, narrowphase and impulse resolution written as ordinary kernel op bodies. There is no C physics anywhere in the program, no engine binding: the collision solver is .kz the way the game is .k. So when the browser build needed physics, nothing was ported — koruc --lang=js compiled the collision engine itself to JavaScript alongside the game. The oracle’s physics is bevy_xpbd_2d, a Rust crate the game calls into; ours is library code the compiler carries across targets like any other Koru. A second backend that can emit a game and the game’s physics engine is a different claim than a second backend that can emit draw calls.

One source, two hosts

The split the game runs on is the facet split: raylib/index.kz is the native host lift (a Zig proc per call), raylib/index.kjs is the browser facet — a Canvas2D body for the same tors. Game code never knows which it’s on.

The interesting case is frames, the frame pump. Natively it is a synchronous loop that calls the | frame arm 60 times a second and returns when it is done. A browser cannot do that — a synchronous 900-iteration loop freezes the tab — so the |js body installs a pump and returns done immediately:

~proc frames|js {
    const __f = { _token: 0, canvas: $mod.__ray.canvas, ctx: $mod.__ray.ctx };
    const __ms = 1000.0 / (win.__fps || 60);
    let __i = 0;
    $mod.__ray.timer = setInterval(function () {
        if (__i >= count) {
            clearInterval($mod.__ray.timer);
            return;
        }
        __i += 1;
        frame(__f);
        $mod.__ray.pressed.clear();
    }, __ms);
    return { tag: "done", done: win };
}

frame(__f) is the caller’s | frame arm, spliced in as a local callback — the continuation survives the host change. The game’s | done arm then runs at install time, not at frame 900, so on this host window.close is a no-op and the pump owns the lifetime. The Koru semantics — “call my arm once per frame, tell me when you’re finished” — are intact; only the clock changed.

The shape of a mechanic

The game is a port — the oracle is ponkatris_arcade, a Bevy 0.12 + bevy_xpbd_2d project, ~5,000 lines of Rust across 44 files including an editor, menus, and ghost replays. The Koru game is the playable slice of one level, so comparing totals would cheat. Compare one mechanic instead: what happens when the marble touches a crystal.

In Bevy, a mechanic is distributed. The crystal is a Component marker plus an ~80-line CrystalBundle assembling sprite, rigid body, collider, restitution, and three builder methods. The collect is crystal_despawner, a system that drains the physics event queue and reconstructs who hit whom by membership test:

pub fn crystal_despawner(
    mut commands: Commands,
    players: Query<&Player>,
    crystals: Query<&Crystal>,
    last_crystals: Query<&LastCrystal>,
    mut ev_collision: EventReader<CollisionStarted>,
    mut ew_crystal_collected: EventWriter<CrystalCollected>,
) {
    for CollisionStarted(e1, e2) in ev_collision.read() {
        let (_player, crystal) = if players.contains(*e1) && crystals.contains(*e2) {
            (*e1, *e2)
        } else if players.contains(*e2) && crystals.contains(*e1) {
            (*e2, *e1)
        } else {
            continue;
        };

        if last_crystals.contains(crystal) {
            continue;
        }

        commands.entity(crystal).despawn();
        ew_crystal_collected.send(CrystalCollected);
    }
}

Then CrystalCollected travels to check_game_ended in another file, and the whole thing is registered into the Update schedule in a plugin. The answer to “what happens on contact” is assembled mentally across three files — that is the price of a model built to dispatch hundreds of systems in parallel.

In Koru, a mechanic is a passage. The crystal is a row in the bodies store; the collect rule reads top to bottom in the frame flow — contact flagged the row, take removes it, the continuation bumps the score store:

pub tor collect { }

collect = std/store:query(bodies)
    ! query e when (e.kind == 3.0 or e.kind == 5.0) and e.hit == 1.0
        |> std/store:take(bodies[e])
            | item _ |> bump-score()

pub tor bump-score { }

bump-score = std/store:query(game)
    ! query g |> std/store:stored {
        g.score: g.score + 1.0,
        g.won: (if (g.score + 1.0 >= g.total) @as(f64, 1.0) else g.won)
    }

Twelve lines, one place, no registration. Neither shape is wrong — Bevy’s distribution is what buys a real engine its parallelism and its editor; Koru’s locality is what buys a 451-line game you can read as one pipeline. The thing worth noticing is that the Koru version isn’t pseudocode: the query arm, the take continuation, and the in-place stored update are the language’s ordinary machinery doing exactly what they were designed to do.

What a real workload catches

Four emitter defects, all invisible to hello-world:

Inline if and typed var. Koru’s inline conditional if (c) a else b and typed declarations like var bonus: f64 = 0.0 are legal inside kernel op bodies and store fields. The JS lowerer passed them through raw — if/else got reserved-word-mangled into if$/else$, and var x: f64 reached node as a syntax error. Both now lower properly: the conditional to a ternary, the declaration to an untyped var:

| kernel k |> std/kernel:self {
        var bonus: f64 = 0.0;
        bonus = (if (k.kind > 0.5) @as(f64, 100.0) else @as(f64, 1.0));
        k.x += bonus;
    }

$mod. in |js bodies. Effect-branch procs splice into the caller’s scope, so a facet that wants its own module-scope state writes $mod.__ray — the sanctioned “my file, not my caller’s” spelling. The JS emitter spliced |js bodies verbatim and $mod. survived as an illegal identifier. It now lowers to the bare file-scope name, the same semantics rewriteModToBare gives the Zig side.

The deep one: the walk that couldn’t see grafted leaves. The browser run threw Cannot read properties of undefined (reading 'handler') every frame — a DOM stub never caught it, Chromium did. Two __store_sweepbody_* events were being called but never emitted. The cause: a take under a query arm leaves its | empty continuation unhandled, so the compiler grafts a synthesized @panic arm as an .inline_code node — and the JS emitter’s implementability walk only vouched for node kinds the parser produces. A transform-produced leaf was refused, the event was skipped, and its only caller lived in transform-emitted text, so the reachability set never saw it either.

The fix makes the walk ask the same question the emitter answers: it trial-lowers the inline text through the same lowering path used at emission. Supported text is admitted; unsupported text still keeps the event out — the gate stays conservative, but it now shares one verdict with the emitter it guards.

pub tor collect { }
collect = std/store:query(items)
    ! query e when e.v == 10
        |> std/store:take(items[e])
            | item i |> std/io:print.ln("took {{ i.v:d }}")

Alongside those, std/fmt learned to honor :d:.4 on the JS lane — Number(x).toFixed(4) — so the HUD timer reads 3.2833s instead of sixteen digits.

Honest edges

Out of bounds is survivable space — the marble keeps simulating past the window and Boost can claw it back blind. The raylib .kjs facet is deliberately thin: it covers exactly what the game calls, no more. And entity kinds are still f64 tags — e.kind == 3.0 — because the language doesn’t yet have the enum the game wants. Those are the next things the instrument is pointing at.

The next game in the wings is Asteroids, which already exists as a native program — and it is the more interesting comparison than it looks. Ponkatris is deeper: a real impulse solver, multi-body contacts, take-under-query sweeps. Asteroids is wider: bullets with lifetimes and cooldowns, rock splits, a hand-rolled LCG, screen wrap — more game systems standing on thinner, hand-rolled kinematics. They stress different machinery, which is exactly what a compiler wants from its next two games.

Measured this week: Asteroids compiles for the JS lane until @sin — the math family has no lowering yet (KORU047), and it needs four of them: sin, cos, exp, log. That is the whole visible list: the raylib facet already covers every call it makes. Four Math. entries stand between Asteroids and the browser — plus whatever the next wall teaches. The gap is also different in kind: Ponkatris hit structural emitter bugs (an implementability walk that couldn’t see grafted nodes); Asteroids hits a lookup table. That is what “closer” looks like — the remaining walls are shallow.