Ten Languages, One Memory Model
Asteroids is live on this site: waves of rim-spawned rocks, splitting collisions, three lives and a ghost-respawn, compiled from main.k to JavaScript by koruc --lang=js and drawn through raylib’s Canvas2D facet. The same source builds the native binary. To find out how the implementation stacked up, we put it next to Brad Cypert’s asteroids-demos — nine more ports of the same game, in Ada, C, C++, C3, D, Go, Odin, Rust, V, and Zig.
It is the thing being discussed, so it should be the thing you can touch:
←/→ or A/D to rotate · W or ↑ to thrust · hold Space to fire · Enter restarts, mid-wave too — the panic button is always armed. The window above is the Koru program itself — no iframe, no port.
Same contract everywhere: 800×600, rotation 3.5, thrust 220, drag 0.60, speed cap 380, bullets at 520 dying at the edge, three lives, radii 40/22/12 scoring 20/50/100, and a wave counter that respawns 3 + wave rocks on a cleared field. One author, one design, ten languages.
The finding that was not in the numbers
We measured RSS, CPU, binary size, and lines of code. We will get to those — but the thing worth a post is something no benchmark prints: all ten reference implementations store the world the same way. Fixed arrays, compile-time sized, every slot carrying an active flag:
// every one of them, structurally:
Bullet bullets[32]; // .active = false means dead
Asteroid asteroids[64]; // .active = false means dead C has bool active. C++ has std::array with bool active. Go, Rust, Zig, Odin, V, C3, D, Ada — same pools, same capacities, same flags, same scan-for-a-dead-slot insertion. Whatever the port was written to compare — syntax, tooling, feel — the memory model is not one of the variables: it is the same design in all ten files, because a fixed pool is the received shape of a game entity world. It is so ubiquitous it never gets a name, and Asteroids is small enough that it is never wrong enough to notice.
Koru is the only different answer in the room. The whole world — ship, rocks, bullets — lives in one kind-discriminated std/store, capacity 128, thirteen f32 fields per row. There is no active flag because there are no dead slots: insert appends a live row, take removes one, the store is dense. A cleared wave actually frees memory instead of leaving sixty-four flagged corpses. dead exists as a field, but it marks pending removal within this tick, not a slot’s permanent occupancy status.
Is the store better? For a game this size, honestly, it does not matter — and that is what the numbers show.
None of this is impossible in C
A necessary concession, because the previous section could be misread as a capability claim. A dense kind-tagged array with insert/take is an afternoon’s work in C; Rust has generational-arena crates for exactly this shape; Zig’s std has everything required. If the reference set had wanted this model, it would have it.
The observation is not about capability — it is about defaults. In Koru, std/store is the idiomatic reach for a population of things that come and go: queries, sweeps, insert and take are just how you hold them. We did not perform the store as a feat; it is where the language lands you when you write the obvious code. The fixed pool in the references is their obvious code, and just as correct for this game. What a comparison like this can actually measure is never “can language X do Y” — everything can do everything — but what each language’s path of least resistance produces, and what it costs when you take it.
The numbers, with the concession up front
Process-level memory is not a signal here. Raylib’s own footprint — the window, the GL context, the framework — sets a ~95MB floor that swamps every implementation’s actual game state, which is single-digit kilobytes in all eleven. We measured anyway, because conceding thin signal without publishing the measurement is how you get “did you even check?”
| implementation | RSS avg (20s steady) | CPU avg | code lines | binary |
|---|---|---|---|---|
| C | 94.3 MB | 10.9% | 294 | 34 KB |
| C++ | 95.0 | 10.0 | 291 | 36 KB |
| Koru | 95.2 | 9.0 | 341 | 74 KB |
| Rust | 94.9 | 8.8 | 463 | 451 KB |
| Zig | 94.9 | 9.6 | 330 | 542 KB |
| Go | 105.4 | 8.4 | 379 | 6.3 MB |
Five references we could build on this machine (raylib 6.0, macOS, Apple Silicon), plus Koru. Ada, C3, D, Odin, and V are in the table by lines of code only — no toolchains installed. Code lines exclude blank and comment-only lines, same rule both sides. Binaries are optimized builds; C, C++, Rust, and Koru link raylib dynamically, Zig and Go statically. Writing this table surfaced a real toolchain fix: Koru’s first release build was 235 KB — 137 KB of it Zig’s self-hosted DWARF unwinder, linked by the default panic handler to print traces no optimized binary can read. The emitted program now swaps in the minimal panic handler outside Debug; the game itself is ~22 KB of code.
Read the table honestly and it says: parity. RSS inside a 1MB native band, Go paying ~10MB for its runtime. CPU mid-pack — 9.0%, below C, C++, and Zig, while drawing jagged ~10-vertex rock polygons where every reference draws a DrawCircleLines fan. (We built a circle-drawing variant of ours to check whether the prettier rocks were buying the parity with hidden line count: they cost three lines. Three.)
The code-line row deserves the asterisk it got. Koru’s 341 is dead-center of the reference median (~339) — but ours splits 265 lines of headless deterministic simulation plus 76 lines of raylib shell, where every reference fuses sim, input, and drawing into one file. And the sim file is the most heavily commented source in the comparison: 216 of its 481 total lines are comments and whitespace narrating the store architecture. Strip them and the granularity tax we feared — one tor per line, every intermediate named — costs nothing at all.
What parity buys
So if the dense store and the split cost nothing measurable, what do they buy? The thing none of the ten have: the simulation runs without a window. tests/death.k kills the ship, waits 1.5 seconds of respawn clock, verifies a life was spent exactly once, restarts on Enter, and asserts wave 1’s four rim rocks at byte-exact coordinates — (104,0), (800,312), (0,546), (368,600) — off a fixed seed. tests/smoke.k runs 120 frames headless. dom_stub_test.js verifies the browser emit against a fake DOM, including double-evaluation across SPA revisits.
A fixed pool with active flags could do all of that too, in principle — none of the ten do, because in a fused sim-plus-draw file there is no “without a window” to run. The split and the store are what make the game an instrument: the same world rules run natively, in the browser, and in a test that never opens a frame.
The honest conclusion after measuring everything measurable: ten implementations converge on ~95MB and a memory model so standard it is invisible, and Koru’s different answer lands inside the same band while drawing better-looking rocks. The benchmark cannot tell them apart. The architecture can — and that, in the end, is what a comparison like this is actually for.