How GrogVM was built

The Why is a love letter. This is the how: three weeks of arguing with an AI about the cleverness of a virtual machine from 1990.

I started on May 25th, 2026 with an empty project and a Play button that did nothing. Three weeks and hundreds of commits later, The Secret of Monkey Island plays from the boot screen to the end credits in a Chromium browser tab, with no app backend and no emulator underneath. About 35,000 lines of TypeScript for the engine, another 6,000 for the harness that plays the game to prove it still works.

It was never a straight line. There were plenty of reverts, one struggle I still haven't won, and a lot of days that were nothing but fixing bugs. One bug per room, found by actually playing.

Day zero was about trust

You don't start a SCUMM engine by drawing anything. Especially if you don't know what the heck you're doing. So first you ask the AI if it's even possible. Half of the answer mentions stuff you've never heard about (WTH are jiffies, z-planes or CLUTs?) but you have to trust the process, there's no other way. The only thing I know is that after 20 years writing software I developed a strong sense of smell for bullshit, hacks and workarounds. I'll have to trust my nose. I asked the agent to craft a high-level building plan based on our discussions, and this is how it looked:

- Phase 1 — Resource catalog: Walk the block tree, dump everything.
- Phase 2 — First pixels: Decode palette + room 1 background to Canvas2D.
- Phase 3 — Costumes: Decode and draw an actor frame, with Z-plane masking.
- Phase 4 — Text: Decode `CHAR` glyphs, render dialog.
- Phase 5 — VM skeleton: Script slots, variables, opcode dispatch, boot script.
- Phase 6 — Enough opcodes to walk: Reach the SCUMM Bar.
- Phase 7 — Verb UI + input: Click-to-walk, look-at, pick-up.
- Phase 8 — Save states.
- Phase 9 — Audio: iMUSE + AdLib first; MT-32 and CD redbook later.
- Phase 10 — MI2 + polish.

LOL: I had no idea what I was getting into.

Day one was a dump

The game's data files are a tree of nested binary blocks, and before you can render a single pixel you have to walk that tree and say "this is a room, this is a script, this is a costume." So the first real commit was exactly that: a block-tree dump. No graphics, no sound, just proof I could read the format the way the original engine did.

The next day: first pixels. A room background, decoded from the game's own compressed bitmap format and painted onto a <canvas>. That's the moment it stopped being a parsing exercise. When I saw the first room on screen I almost cried.

From there it went layer by layer: the costume format and the actor compositor, the bitmap font and its renderer, then the VM itself: a bytecode interpreter for SCUMM's ~100 opcodes. Only then could the thing boot itself: load the first room, place the actors, and start running the game's own scripts.

Teaching Guybrush to walk nearly broke me

If one struggle captures the project's early days, it's the walk animation.

Drawing a static sprite is one thing. Getting Guybrush to walk (legs cycling, body holding, head pointing the right way, mirrored when he turns west) meant decoding the costume animation format, and I got it wrong over and over. I wired up a walk trigger, it flickered and showed two heads, I tried to fix the flicker, reverted both changes, and committed a note that just said "document the reverted walk."

The real bug was a 6-byte misalignment. Every offset stored in the costume data is relative to a base pointer six bytes earlier than where my decoder thought the payload began. Once I figured out that the record, the command stream, and the limb image table all had to be read at payload[value − 6], the double head resolved into one cycling body with the head held still, exactly as the original does it. That single −6 is the difference between a pirate and a weird puppet.

I stopped trusting "looks right" after that. The fix only shipped once I'd checked it headlessly across the intro's nine-actor scenes and with my own eyes.

Playing the game was the test

Here's the part I didn't expect. Once the first room was playable, the fastest way to find bugs was to keep playing, and the fastest way to keep them fixed was to teach the agent to play too.

So the real spine of this project is the tooling that comes with it, mainly in the form of a harness enabling the agent to drive the engine headlessly and a single integration test that boots the game and plays it, beat by beat, the way a person would: walk here, look at that, use this with that, pick the right insult in a swordfight. It runs headless in seconds. Every time I pushed the playthrough one room further I hit a new wall: an unimplemented opcode, an actor drawn behind scenery it should be in front of, a door that wouldn't open. That wall became the next commit.

While I initially played the game, reported the issues and then asked the agent to write the corresponding beats in the walkthrough, at some point the flow started running in the opposite direction. I'd give the agent rough gameplay instructions for the next 2-3 beats, and the agent would figure it out with a process like the following:

An invaluable piece of the puzzle was the ability to save the VM state at the end of each beat, allowing both me and the agent to pick up the game from any particular point we covered up to that moment. I'd import the save in the browser to eyeball the rooms and identify the visual issues the agent wouldn't notice, and I'd share saves back with the agent whenever I was stuck in a situation that wasn't covered by the walkthrough.

With this in place, whole days read like a march through the game's geography:

By the end the harness walks all four parts from boot to credits, and the game has been completed in a Chromium browser too. When those signals are green, the game is playable end to end; the remaining rough edges are visual and audio fidelity, not the core flow. That was the whole point.

The eyes that won't stay on

Not everything got solved, and that's worth sharing.

VGA mode 13h stretched 320×200 to fill a 4:3 monitor, so the original's pixels were taller than they were wide and the art was drawn assuming that. That part I fixed: present at 4:3, and the proportions snap back to how they looked in 1990.

But when an actor walks toward or away from the camera, the engine scales the sprite down, and it has to decide which rows and columns of pixels to drop. Drop the wrong ones and Guybrush loses his eyes. I've spent a genuinely absurd amount of effort here: extracting reference screenshots from the real game as an "oracle," building diff masks gated on costume colors, running optimization over a grid of sampling phases to minimize the frames where his eyes disappear. I got it close. I tried a bit-reversal scale table that matched the static reference shots better, and reverted it, because it made him visibly strut while walking.

So the honest status is: the faithful per-scale pattern is still unrecovered. I'd need frame-accurate captures from ScummVM/DOSBox to crack it properly, and I haven't.

The fire that can't crackle

Sound came in two phases. First a silent timing seam: the engine needs to know how long a sound lasts even when nothing plays, because some scripts wait on it. Then real output: digitized PCM effects and the CD-audio music, wired so the music seeks to the right spot from the VM's own virtual clock. (That clock had its own bug: leaking a fraction of every frame and running ~1–3% slow, which I only realized when I could hear the music re-seeking every once in a while.)

What's not there is the AdLib/OPL2 FM synthesis: about 15 effects that exist only as FM instrument data. I parked it. A deterministic FM synth reproduces a lot, but it can't fake the broadband crackle of the lookout fire without proper hardware captures to match against, and I'd rather have silence than a wrong sound.

The method, since it's an AI-built project

I'm not from the games industry, and I didn't hand-write these 35,000 lines. Claude (Opus 4.7, then 4.8, then Fable 5, then Opus 4.8 again) did the bit-flipping while I steered. A few things made that work over three weeks instead of collapsing into spaghetti:

It reached the end credits on June 14th. And it was AWESOME.

Now go install a copy you are allowed to use and watch the magic work.