I’ve always been curious whether interpreter authors have ever considered porting their code to another language. Or porting someone else’s interpreter, for that matter. I designed Voxam to be (relatively) easy to port, but I’m now going to put that to the test. Incidentally, this post may seem pure vanity. But I’m posting my thinking because I’m genuinely curious to hear others’ thinking and how they have approached this idea.
So, given my design, I’ve been thinking either Go or Rust. Either seems like a big undertaking, but, again, given that design, I think the port is easier than the almost 50,000 lines suggest. Three things about Voxam’s design work heavily in a porter’s favor:
- The core has zero dependencies. The VM cores, blorb/IFF/Quetzal/babel, even the PNG and AIFF decoders are hand-rolled stdlib Python. There’s no library-shaped hole to fill; it’s all byte-level code that translates mechanically. And in Go or Rust, the cores actually get cleaner: all the
& 0xFFFF/& STATE_MASKmasking that Python’s arbitrary-precision ints force becomes native uint16/uint32 wrapping. The opcode dispatch dicts (“random”:Machine._op_random) become switch statements or method tables. Mechanical either way. - The RNG is already language-independent. I deliberately built xorshift32 in-house so recordings would never be invalidated by an interpreter upgrade. That decision now pays off twice. Port those ~50 lines faithfully, and every seeded recording replays identically under the new implementation.
- Which means my certification corpus carries over for free. The 44 acceptance recordings, the regtest suites, glulxercise, and the byte-identical Å-machine batteries are all language-neutral. I don’t need to port thousands of lines of pytest. Instead, port unit tests selectively for the tricky decoders (ZSCII, operand decoding, Quetzal), and let the recordings certify everything else end-to-end. In my experience, a port project with a pre-existing, spec-grade acceptance harness is rare, and this is probably my single biggest asset.
Where the real work crops up is that the codebase splits into three layers with very different port characters.
- VM cores (~32k lines: zmachine 11.6k, glulx 15k, aamachine 5.5k). That’s mechanical translation, low risk, recordings as the net. The Glk layer (glulx/glk/, ~7k lines with its object registries and dispatch) is the subtlest part.
- Binary formats (blorb, iff, quetzal, png, aiff, babel, listing, sixel). That’s mechanical byte work. Go’s stdlib even hands me image/png while staying zero-dependency, so png.py could vanish outright.
- Frontends: These are rewrites in kind, not ports, and this is where the design effort concentrates.
One architectural difference I would have to plan for: Pygame lets the interpreter block for input within its own event loop; Ebitengine (my likely replacement) inverts control: it owns the loop, calls your Update/Draw at 60 fps, and you must never block. The idiomatic Go answer is for the interpreter to run in its own goroutine, communicating with the frontend over channels. Now, here’s the nice part: that seam already exists in Voxam. It’s the wire, which is my GlkOte stanza protocol. If the port makes that the internal seam for every face, then bubbletea (to replace my blessed), Ebitengine (to replace my Pygame), the web tab, and stdin all become consumers of one channel protocol, and the desktop shell keeps working unchanged since it already drives glkote down a pipe.
There is a caveat with bubbletea: it uses the Elm architecture (model/update/view), whereas my glass.py/painter.py paint terminal cells directly, the way blessed does. tcell (the library bubbletea itself sits on) is the closer stand-in for my painted glass: direct cell-level control, no framework inversion. Bubbletea would mean redesigning the glass, not porting it.
But what about Go vs Rust?
Go’s case is fairly simple. It matches the project’s spirit: Voxam is stdlib-only Python, and Go’s stdlib covers PNG, HTTP, and JSON, in the same batteries-included way. Goroutines+channels dissolve the blocking-input problem naturally. Ebitengine bundles cross-platform audio, which replaces both pygame and sounddevice, thus no more PortAudio apt-install caveat on Linux. And distribution collapses to a single static binary: the entire pip install “voxam[screen,graphics,sound]” extras story becomes “download voxam.exe, every face included.” Iteration speed stays close to Python’s.
Rust’s case seems to have two genuine advantages. First, sum types with exhaustive matching are philosophically aligned with my spec-fidelity ethos: an opcode/operand enum makes the compiler enforce the coverage my 100%-branch gate currently enforces by discipline, and Wrapping<u16> makes overflow semantics explicit in the type. Second, my desktop shell is already Tauri, i.e., already Rust. A Rust core could eventually link into the shell in-process instead of spawning a subprocess, and Rust’s wasm story is mature enough to run the interpreter in the browser page rather than server-side behind --web, as I currently do. On the frontend side, ratatui is excellent (arguably stronger than bubbletea for grid-and-window layouts), but the pygame-equivalent story is weaker. I guess macroquad or ggez work, but neither is the community default Ebitengine is, and I would have to assemble sound (rodio/cpal) separately. Plus, the borrow checker adds real ceremony to the interpreter-thread/frontend-channel plumbing that Go gives me for free.
So, my current recommendation to myself is Go. The cores port equally well to either; the frontends decide it, and Go’s frontend path (tcell + Ebitengine + net/http) is a paved road where Rust’s is (or at least feels like) a parts list. I think, at this point, I would choose Rust only if in-browser wasm or in-process Tauri integration is a driving goal rather than a nice-to-have.
Still noodling on this, and I’m thinking about the Tauri (desktop) port in particular.