Pocket Voxel: A Creature-RPG From First Principles
A Game Boy creature-RPG and the mod that presents it as a walking 3D voxel diorama, both Lua on LÖVE, rewritten as a TypeScript gameplay guest and a Rust scene core on a real 2004 PSP. You bring the cartridge. Inside: the inverted split that puts the whole game state in QuickJS, luajit re-running the original as a bit-for-bit oracle, the hardware bug determinism could never catch, and the campaign that took outdoor frames from 102 ms to a locked 30 fps.
Written by Yifeng "Evan" Wang

Pallet Town on a PSP-2000, photographed by the machine itself over PSPLINK. Every screenshot in this post is a hardware capture; none of these pixels live in any repo, for reasons the post gets to.
There is a Lua project called gen1recomp that re-implements the first-generation Game Boy creature-RPG (the one with the red cartridge) as a clean modern engine that reads your own ROM for its content. And there is a mod for it, DramaticShape's Voxel Mod, that does something quietly spectacular: it re-reads the flat GB tile maps as architecture, so walls get height, roofs get gables, trees get carved into little round hulls, and the whole game becomes a walking 3D diorama. Both run on LÖVE, the C++ game framework you script in Lua. Both are desktop programs.
We rewrote the pair, gameplay into TypeScript and renderer into Rust, and now the diorama runs on a 2004 Sony PSP: 333 MHz, no shaders, no JIT. The result is Pocket Voxel, a specialized runtime of PocketJS, and this post is the story of the port: why a rewrite rather than a port of the engine, what a creature-RPG looks like rebuilt from first principles in TypeScript, and how two Lua codebases we never vendored kept every formula honest anyway.
Why rewrite, and why these languages
The lazy framing is "LÖVE doesn't run on a PSP," which is true but not the reason. Someone determined enough could port a Lua interpreter and enough of LÖVE's surface to a handheld. The reason is that the shape of a desktop engine is wrong for this machine, in ways no porting effort fixes:
- LÖVE is a runtime-everything engine. The Voxel Mod classifies tiles, measures building volumes, carves tree hulls, and meshes chunks while the game runs, on a machine with cycles to spare. The PSP has no cycles to spare; it wants the console discipline PocketJS inherited from Pocket3D: move every cost you can to build time, and ship bytes the GPU reads in place.
- Lua's performance story on this hardware is our JavaScript story, without our escape hatch. Any dynamic language on a 333 MHz in-order MIPS core runs interpreted and is allergic to per-frame work. PocketJS spent its first month building exactly this answer: a measured budget for guest work (QuickJS: ~1.7 µs per host call, ~8k calls per frame), a native core that owns everything per-pixel and per-vertex, and a deterministic test culture that makes the split safe to live with. Rewriting into that runtime is cheaper than rebuilding that runtime around Lua.
- A product should outlive its first machine. The gameplay compiled as a QuickJS guest bundle is the same artifact on a PSP, a Vita, or a desktop, which is the claim OpenStrike proved for an FPS. A LÖVE program is welded to LÖVE.
So: TypeScript for the game, because the game is product logic (rules, menus, text, saves), and product logic is what the PocketJS guest tier is for. Rust for the renderer, because voxel chunks, culling, and a fixed-function GPU are what the core tier is for. Which leaves the real question of the project: if you're rewriting everything, what stops the rewrite from quietly becoming a different game?
The answer is the method the whole port ran on: the Lua is the spec, and the spec is executable. Both upstreams are MIT-licensed, and neither ships a line of code into our tree. Instead, luajit plus a small LÖVE stub runs the original engine headless on the build machine, as an oracle. Port a formula, then make the oracle print its answers, and diff bit-for-bit. Every ported function carries a provenance comment like // gen1recomp BattleState.lua:3397, naming the exact Lua it must agree with, and a later line-by-line audit of the shipped port found the rules layer verbatim-correct, with every real gap in the glue around it. You can't get that guarantee porting from a design document, because a design document doesn't run.
One more inheritance, this time of a legal stance rather than code: like upstream, Pocket Voxel is ROM-fed. The only content input is a canonical Gen-1 ROM you already own; the importer checks its SHA-1 before decoding a byte, everything derived from it lives in git-ignored dist/, and no ROM-derived byte is ever committed: no cooked pak, no extracted art, no golden PNGs. The rendering goldens in CI are frame hashes. The screenshots here exist because a real PSP drew them.
The inversion
Every previous runtime in this family put the simulation in Rust and gave JavaScript the layer on top: OpenStrike's core owns movement, bots, and bullets while rules.ts owns round flow. Pocket Voxel flips it completely: the game state lives in the guest. Movement, collision, NPCs, warps, the script VM, text pagination, menus, the entire battle engine, the party, the bag, the save file, the RNG: all of it TypeScript, all of it inside QuickJS. The Rust core owns presentation only: cooked voxel chunks, up to 16 entity billboards, the camera, a battle stage, a retained 20×18 GB UI tile grid, and a chip synth that renders the ROM's own sound programs to PCM.
What makes the inversion viable is arithmetic, not ideology. The QuickJS budget on this CPU is ~8k host calls per frame, and a Gen-1 RPG is, architecturally, a low-frequency state machine. The player crosses a tile in 16 ticks. A textbox reveals one glyph per beat. So the guest describes its world to the core through a retained-scene protocol of tiny ops (cam, ent, uiText, mapShow, arena…), and steady-state boundary traffic measures a couple of ops per tick, 10–40 in a busy frame, three orders of magnitude under the wall. Opening a menu bursts a few hundred ui* ops, once. The trick that keeps text cheap is telling: the guest sends a message once, and the typewriter effect is a single retained reveal counter the core compares against. That is the same relational, retained-scene idea PocketJS's UI surface runs on, applied to a diorama.
A creature-RPG from first principles, in TypeScript
"Port the gameplay" flattens what was actually a re-architecture. The Lua engine is a fine desktop codebase of its era: 1-based indices, module-level state, LÖVE callbacks woven through the logic. The TypeScript is organized around a different center of gravity, purity at the leaves and one writer at the boundary, because that's what makes the oracle usable and the whole thing testable in milliseconds:
apps/voxelmon/game/ (~11k lines of TypeScript)
├─ rules/ eleven pure formula modules: damage, stats, growth,
│ typechart, turnorder, catching, experience, status,
│ encounter, timing, bag. No I/O, no globals; every
│ function takes the RNG as a parameter. Each formula
│ cites the Lua it ports, and luajit re-runs that Lua
│ to check the answers bit-for-bit.
├─ world/ map (the bottom-left-tile collision rule), player
│ (16-tick steps, the 4-tick armed-turn window, the
│ wall-bonk), npc wander, warps and doors, the textbox
│ paginator, a generator-based script VM (its verbs
│ are the reference's verbs), and the overworld
│ controller, which preserves the reference engine's
│ update order line by line.
├─ battle/ the message/action queue IS the engine (say, act,
│ drain, wait, statBox), exactly as upstream; an effect
│ registry covering every move reachable on the cooked
│ maps, with the reference's own fallbacks for the rest.
├─ scene.ts the one delta-emitting frontend: diffs game state
│ into surface ops. Nothing else touches the boundary.
├─ game.ts the state stack: overworld / textbox / menu / battle.
└─ data.ts typed loader for the imported datasets. Bun reads
JSON on the desk; QuickJS gets one cold parse at boot.The discipline that pays off most is the smallest-sounding one: rules take their randomness as an argument. The Lua test harness injects fixed and sequenced RNGs to pin behavior; because the TS mirrors that shape, the same injectors drive both sides of the parity suite, and a damage-roll disagreement is a diff, not a debugging session. The suite currently stands at 226 tests and 47,715 assertions, and the heaviest of them are exactly these cross-language matrices.
What does a frame actually look like? Here is the whole per-tick shape, trimmed of its profiler hooks. Sixty times a second the host calls the guest exactly once; the guest updates only the top of its state stack (a textbox freezes the world beneath it, exactly as upstream's stack works), then lets scene.ts diff what changed into surface ops:
// game.ts: one guest turn per host tick, exactly once
tick(buttons: number): void {
this.input.setButtons(buttons);
this.input.step(); // edge-per-step input, Input.lua:109
const top = this.stack[this.stack.length - 1];
top?.update(); // ONLY the top state runs this tick
this.scene.emit(this); // diff game state into surface ops
this.driveAudio(); // hand the core its music/sfx cues
this.host.frameDone(this.tickIndex, buttons);
this.tickIndex += 1;
}While you walk, the top state is the overworld controller, and its update is a preserved copy of the reference's order, because that order is load-bearing: the script VM must win over the d-pad, and an emotion bubble freezes NPCs but not the player's step animation. Trimmed:
// world/overworld.ts: OverworldController.lua:883 update, same order
update(): void {
this.runner.update(); // the script VM gets the frame first
if (this.emote) { // an emote bubble holds the world for
this.player.update(); // a beat; only the player animates
return;
}
for (const npc of this.npcs) {
npc.update(this.map, this.entities, this.shell.npcRng, this.tilePairs);
}
this.updateScriptMoves();
const scripted = this.runner.isRunning() || this.scriptMoves.length > 0;
if (!scripted && !this.transitioning) this.handleInput();
const stepped = this.player.update(); // 16-tick grid steps
if (stepped && !scripted) this.onStepComplete();
}That last call, onStepComplete(), is the landed-step gauntlet, again in the original's order: warp-entry staleness, the standing-on-warp flag, arrival warps, held-direction collision warps, and only then the wild-encounter roll for the cell you landed on (grass, surfed water, or, on indoor maps outside the forest tileset, every tile, exactly as wild_encounters.asm has it). Walking into Route 1's tall grass and meeting a wild bird is that final line rolling against the ported encounter table.
Above the rules, this world layer is where a from-scratch rewrite would silently drift, because this is where thirty-year-old game feel lives. The reference is full of numbers that are load-bearing without being documented anywhere except the original 8-bit assembly: a step is 16 ticks; a direction press has a 4-tick window where it turns you in place before it walks you; bonking a wall animates a walk-in-place; every text beat and HP-drain speed traces to a cited line of the original asm via the Lua's own timing table, which the port copies constant-for-constant. This is where "the spec is executable" stops being a slogan: you don't have to notice that ledge hops can land off-map, or that warp entry is positionally disabled after arrival, because the oracle's test harness already encodes it, and a tape that walks the route fails if you got it wrong.
Battle needed a different backbone, and the reference's is worth copying precisely because you would not design it from scratch: the battle engine is a message and action queue, and the queue is the engine. Text pages, HP-bar drains, animation beats, and state mutations are all rows, executed in order by one pump; the builders keep upstream's insertion semantics (say appends, sayNext inserts right after the row being executed), because half of Gen 1's battle feel is when a line of text appears relative to the HP bar it explains. Here is a faint, composed as rows:
// battle/battle.ts: BattleState.lua:3624 onFaint, as queue rows
onFaint(battler: WildBattler): void {
if (battler.faintQueued) return;
battler.faintQueued = true;
this.actNext(() => { battler.fainted = true; }); // staging hides the card
this.insertNext({ wait: FAINT_SLIDE });
if (!battler.isPlayer) {
// core.asm:792: the victory theme starts AS THE SLIDE LANDS, before
// the fainted text and the exp text, not after the box is dismissed
this.actNext(() => this.audioCues.push("music:victory"));
}
this.sayNext(`${displayName(battler)}\nfainted!`);
if (battler.isPlayer) this.act(() => this.playerMonFainted());
else this.act(() => this.enemyMonFainted());
}Even damage is a row: applyDamage subtracts hit points and queues drainNext(target, hp), and that row holds the pump while the bar ticks down at the reference's own drain speed from the ported timing table. The formulas the rows carry (damage, crit, accuracy, catch) come only from rules/; the queue never computes, it sequences.
Evolution is the layering at its cleanest, because one feature crosses all three floors: a pure rule, a battle-exit hook, and text pages. The rule half lives in rules/evolution.ts and is careful about a Gen 1 subtlety that is easy to get wrong: after a battle, only mons that gained a level in that battle are checked, so a mon that qualified earlier waits for its next level-up:
// rules/evolution.ts: Evolution.lua:195 checkParty, the decision half.
// Pure: returns the queue, mutates nothing; the caller owns the pages.
export function checkParty<T extends EvoMon>(
data: VoxelmonData,
party: readonly T[],
leveledUp: ReadonlySet<T> | null | undefined,
): { mon: T; to: string; evo: EvolutionEntry }[] {
const pending = [];
if (!leveledUp) return pending;
for (const mon of party) {
if (!leveledUp.has(mon)) continue; // only mons that leveled THIS battle
const hit = pendingFor(data, mon, { kind: "levelup" });
if (hit) pending.push({ mon, to: hit[0], evo: hit[1] });
}
return pending;
}The shell half runs where upstream's afterBattle runs, at the battle-exit site, and drives the pending list one page at a time: apply mutates the mon (stats recalculated for the new base stats, current HP keeping the same HP lost, dex flags set), the "Congratulations!" page shows, and then the evolved species' learnset is checked at exactly this level, because a mon that evolves at a learnset level gains that move and one that evolves a level later does not. None of that nuance was designed here. All of it was read out of the Lua, ported with its citation, and pinned by a test.
The renderer moved from run time to cook time
The Voxel Mod's renderer is a beautiful piece of interpretation: it looks at flat GB tiles and decides, live, what is wall and what is roof and what is tree. It can afford to; it runs on a desktop, inside the game process. The PSP cannot, so the port's structural move is to split that renderer in half along the build-time/run-time line, exactly like a bundler:
Everything the mod decided per-frame, the cooker decides once: the tile classifier with its conditional pins (one tile id can be a wall base and a shop counter, disambiguated by what sits above it), the repeat-aware building measurement that votes on heights across a facade, authored building templates applied in a four-stage read-measure-model-emit pipeline, tree canopies carved band-by-band into round hulls, props segmented from their sprites pixel by pixel, ambient occlusion computed into vertex shade. The PSP consumes finished 16×16-tile chunk meshes, zero-copy, the way OpenStrike consumes a cooked BSP. The importer feeding all this is worth a sentence of its own: it is manifest-driven, consuming the reference project's 3,274-entry symbol table verbatim rather than transcribing offsets, and its 16 output datasets are field-for-field parity-checked against the reference's own extractor before anything downstream trusts them.
The sound programs, or: an own goal, admitted
Audio is the one place "presentation is data" earned an asterisk, and we earned it the expensive way. The first synth was a faithful TypeScript port of the reference's DMG chip interpreter (envelopes, vibrato, the noise LFSR, all of it), and we shipped a device build believing its switch was off. The device got laggy and musical at the same time; we blamed threading. The truth, dug out by a proper audit: the switch had been on the whole time (setAudio(null) meant "load the banks from the pak," and the comment beside it said the opposite), and the lag was the synth itself. The arithmetic is brutal and worth stating as a law: one second of 11 kHz chip audio cost ~2.3 seconds of CPU in interpreted JavaScript on this machine. An interpreted synth on device isn't over budget; it's arithmetically impossible.
So the synth moved into the Rust core, and the TS port was deleted the same day; keeping it as a "reference" would have enshrined our own intermediate artifact as truth. The real reference is the upstream ChipSynth.lua, run under LuaJIT, and the Rust interpreter was verified against it across all 303 sound programs in the ROM (45 songs, 104 effects, 154 cries), five seconds each: zero differing samples out of ~200 million. That sweep caught three rounding bugs no hand-written unit test would have found. Cost on device: the guest names a song in numbers, the core synthesizes PCM at a fraction of a millisecond per tick, and the ring is pumped inside the GPU's own wait bubble, where it is effectively free.
One tape, four executors
Determinism is the family religion, and this port kept it strict. Input is an intent tape: walk three tiles north, press A, wait. Never frame counts. Logic steps at a fixed 60 Hz with a seeded RNG. So one tape that walks from the player's bedroom, downstairs, out into town, north through the tall grass (fighting the wild bird it finds there), and on to the next town is not a demo; it is the artifact everything else is checked against:
The committed goldens are hash lines, fifteen per tape, because pixels would be ROM-derived. When the emulated GPU and the software rasterizer are compared image-to-image at the story's eleven marks, they agree within a documented seam-rounding tolerance; when a change is supposed to be invisible, the hashes say so byte-for-byte, which is what later made it safe to rip the renderer's internals out repeatedly in the name of speed.
For the record, because it still surprises us: the distance from an empty directory to that whole chain green (importer parity, rules oracle, overworld, wild battles, the Rust core, the cooked pak, the sceGu EBOOT, the emulator end-to-end) was one working session; the draft PR went up four hours and eighteen minutes after the first prompt. Five research agents mapped the two upstreams and our own substrate in parallel; a design doc and a codegen'd, drift-guarded surface contract pinned the seams; and from there every port proceeded against an oracle instead of against hope. Thirty-four hours later the project moved out into its own repo. In between came the part no oracle covers.
The machine disagrees
The first time the EBOOT ran on real hardware, the world was alive: an NPC wandering Pallet Town, water animating, the diorama standing up in perspective. The player, though, was a rectangle of colored static. The emulator showed nothing wrong. A day of PSPLINK loops (rebuild, ldstart, screenshot, diff against the rasterizer, about 12 seconds a cycle) turned that one symptom into three distinct bugs, and each one is a lesson about this class of hardware:
- Textured 3D draws must use 16-bit indexed vertices. Float vertex formats, which the emulator accepts happily, sample garbage on the real GE. Every textured path now speaks i16, and the software rasterizer truncates identically so the two can never disagree about a pixel.
- CLUT8 texture pages must be at least 64 pixels wide. Our sprite sheets were 16-pixel-wide pages, which real silicon missamples into vertical-strip noise. The emulator's software renderer actually agrees with hardware here, but the end-to-end test's fuzz tolerance had been quietly absorbing the difference. The tolerance was hiding a law.
- The third bug, determinism could never have caught. The guest was passing the ROM's sprite index where the surface wanted an atlas page; page 0 is the terrain atlas, so the hero wore the tree texture, and NPCs, whose indices happened to land inside the sprite range, wore each other's clothes and looked plausible. Both the GE and the reference rasterizer executed the same wrong op stream in perfect agreement. Cross-executor comparison proves consistency, not correctness; this one took a person holding the device saying "the player flickers like a tree," twice, before we believed the report over the green tests.
The same loop settled the rest of the launch-day list. The game was grayscale because the Game Boy was; color is per-tile palettes from the reference project's pokered-gbc-derived tables (Red itself ships no color code), cooked into the pak like everything else, with both backends resolving every draw's palette through one shared function so CI and silicon cannot drift by a CLUT entry. Dialogue crawled at six frames a second because the UI layer issued one upload and one draw call per tile (a textbox is a hundred tiles) and the guest was re-encoding glyph strings every tick; one batched upload plus an indexed encoder took dialogue frames from 145 ms to 10.7 ms. Every one of these was invisible on the desk and undeniable on the glass.

Route 1, on device. The tall grass is the expensive way to render an encounter zone: every tuft is real extruded geometry, a decision you get to make when meshing costs nothing at runtime, and pay for below.
Where the milliseconds went
Indoors held 60 fps almost immediately. Outdoors, day one measured 84 ms a frame, and the honest ledger said why: the worst story frame carried 110k triangles, over half of them carved trees, against a GPU that the first measurements said could feed ~18k per 60 fps frame. There was a cheap fix on the shelf, replacing carved trees with textured boxes (84 → 26 ms), and it was the wrong fix, because the carved trees are the product. The steer that reframed the endgame: this runtime will target many machines, so fidelity must be a ladder: named tiers in the spec, one cooked pak serving all of them, and the top rung pinned bit-identical to the pre-ladder picture by committed frame hashes, so no optimization for the PSP can quietly redraw the game everywhere else.
Then the PSP rung had to be won rather than configured, and the campaign ran on one instrument: an autopilot build that replays the story tape on the physical device and phase-logs every frame (guest, scene build, GPU wait, vblank, GC) over the PSPLINK cable. Fifteen lettered telemetry runs in one day, every change an A/B against the same scripted walk. Three findings decided it:
- The CPU was the first beast, and it was death by a dozen cuts. Re-staging grass and flower vertices against the camera each frame cost tens of milliseconds of software square roots; drawing in place under a constant depth bias, exact at the camera's focus, deleted the pass. The guest's map emitter re-ran a search every tick that an identity check made free (8.1 → 0.11 ms), with the goldens proving the op stream stayed byte-identical. The JS engine's GC fired 175 ms collections mid-walk; now it runs on warp landings, hidden behind the screen cut. And the audio pump turned out to self-aggravate: below 60 fps each tick synthesizes catch-up PCM, making slow frames slower. Moving the pump after the GPU kick let synthesis run inside the wait bubble for free.
- The GPU is fetch-bound, and it confessed through an accident. Feeding it indices spliced through the per-frame pool was mysteriously faster than clean static buffers, because the spliced bytes had been CPU-written moments before and were still bus-warm. Cold, the GE pays ~0.7 µs per triangle on vertex fetch whether the triangle faces you or not, which closed a whole category of standard advice: back-face culling and instancing buy nothing here (we measured both; one experiment got reverted the same hour). The only levers are triangles that don't exist and bytes that are smaller. So trees got a half-resolution carve whose UVs still span full-resolution art, low terrain got painted into a per-chunk oblique-projected ground bake, building facades folded into the same texture page, detail streams learned to draw a spatially uniform prefix, and the vertex slimmed from 20 to 16 bytes, a flat −20% on everything.
- A boundary you can see is a bug. The optimized rung shipped with distance thresholds (fine trees near, coarse far; live geometry near, baked far; grass fading at a ring), and a person walking the route reported all three within the hour: trees twinkling as the line swept them, the road popping baked↔live one step ahead, grass materializing at its radius. (The same pass surfaced a confession: the "uniform half-density" the code comments promised had never been implemented; half density was drawing the north half of each chunk's grass.) The fix is now the rung's governing rule, pinned in the spec: no camera-relative representation change inside the visible field. Every distance dial is unbounded or off; the budget is paid with uniform measures that cannot flicker because they never switch. It shipped faster than the artifact config it replaced.
The last number is the one we argued about most. Outdoors landed at 28–43 fps (real 60 was another geometry diet away), and an uneven 28-to-43 feels worse than it sounds, because what reads as stutter is the alternation between two- and three-vblank frames. So the shipped rung locks presentation to an even 30 fps beat while logic stays at 60 Hz, two game ticks per presented frame, and the telemetry shows exactly 150 presented frames per 300 ticks in every window of the walk. An even beat is what "smooth" actually is. Interiors, meanwhile, run at 59–65 fps, and a desktop-class host asks for the top rung and gets the pre-ladder picture, pixel for pixel.
What this actually proves
Pocket Voxel started as a homage to two Lua repos and ended as the family's strongest datapoint for a claim we hadn't tested: the guest tier can own an entire game, world, battles, scripts, saves and all, as long as the boundary is a retained scene and the traffic respects the measured budget. A couple of ops per tick is not a compromise; for this genre it is the natural shape. And the porting method generalizes past this pair of upstreams: if the program you are rewriting runs, it isn't documentation; it's an oracle. Run it headless, pin its answers, cite its lines, and the scariest question of any rewrite (did we change the behavior?) becomes a diff in CI.

Where every save file starts. The furniture is per-pixel extruded prop geometry: the cooker's work, consumed as bytes.
The gaps, named, because that is house policy: wild battles are the shipped battle scope; trainer battles, move animations, and the box system are ports still owed. Boot takes about a minute (QuickJS parsing a megabyte of game data deserves a progress bar, and the data deserves a binary format; both were deliberately deferred, because the standing rule was that nothing may cost runtime frame rate). The pak outgrew a first-generation PSP's 24 MB of user RAM, so the fat-model rung needs mesh instancing before it is honest. And outdoor 60 is not a mystery, just unfinished: the per-kind triangle probe says exactly which geometry goes next.
Try it
pocket-stack/pocket-voxel is MIT, with the same vendored-runtime shape as OpenStrike (vendor/pocketjs pinned as a submodule). Bring your own ROM:
VOXELMON_ROM=path/to/red.gb bun tools/voxel.ts import # SHA-1 gated decode
bun tools/voxel.ts cook # voxelize → voxelmon.vxpak
bun tools/voxel.ts sim --shots # the story tape, rendered headless
bun tools/voxel.ts psp --release # the EBOOT, if you have the hardwareNo PSP? The simulator renders the same trace the hardware replays, and PPSSPP runs the EBOOT. If you do have one: it is the opening of the game you remember, standing up in three dimensions on a machine from 2004, off a cartridge you dumped yourself.
Follow @pocket_js for what's next. The pocket keeps getting deeper.