Zork I box art with the Go gopher peeking out from behind a dungeon door
Zork I box art remixed with the Go gopher. Original art copyright of Infocom.

Zork: The Great Port

24 Apr 2026
26 min read

Zork series cover art
Series
Zork: The Great Underground Empire
Part 3 of 3

In the first article of this series, I explored the history of Zork, one of the most influential games of the 80s. In the second article, I dissected its source code, studying how the parser, game objects, and syntax system worked together to create a text-based adventure game that understood sentences such as attack the nasty-looking troll with a rusty knife. At the end of that article, I mentioned I’d started porting the game to Go. Well, it’s done. The game is fully playable, and the source code is available on GitHub. In this article, I’ll go through why I chose Go, how the architecture translates from ZIL, and how things like Git, CI/CD, and automated testing compare to the development practices of the original Implementors.1

Why Go?

The original motivation, as I described in the second article, was a Kubernetes experiment gone sideways. I wanted a fun web game to use as a test workload, and my first instinct was to build it as a web service using React and Redux. But once I started digging into the ZIL source code and understood the true complexity of the game, the parser, the object tree, the action dispatch chains, the clock system, I realized that a web service approach would require completely rethinking the game’s architecture. The original code is deeply imperative and stateful: global variables, sequential flag checks, and routines that fall through to other routines. Translating that into a React/Redux paradigm would have meant redesigning the game, not just porting it. I needed a language that would let me reimplement the code nearly verbatim first, and then optimize it for the target language later. So I dropped the web service idea and looked for something where the translation could be mechanical rather than architectural.

It was 2020, and I’d been familiarizing myself with Go. I believe the best way to learn a language is to solve real problems in it, and porting Zork seemed like the perfect project for a deeper dive. The codebase was complex enough to force me into Go’s corners, interface design, package structure, and error handling, but well-understood enough that I always knew what the end result should look like. When you’re learning a language, having a reference implementation in another language is invaluable. If the output doesn’t match, the bug is in my Go code, not in the game design.

Beyond the learning opportunity, Go meets the practical criteria as well. I wanted something compiled, something with a good standard library, and something that would produce a single binary I could hand to someone without asking them to install a runtime. Go check all three boxes.

There were also aesthetic reasons. ZIL, despite being a functional language derived from LISP, produced code that was surprisingly imperative in practice. The game logic is full of conditional chains: check this flag, print that text, return. That pattern maps naturally to Go’s if/switch statements. A ZIL action handler like the white house function, which I showed in the previous article, looks like this in Go:

func whiteHouseFcn(arg ActionArg) bool {
    if G.Here == &kitchen || G.Here == &livingRoom || G.Here == &attic {
        switch G.ActVerb.Norm {
        case "find":
            Printf("Why not find your brains?\n")
            return true
        case "walk around":
            goNext(inHouseAround)
            return true
        }
    } else if G.Here != &westOfHouse && G.Here != &northOfHouse &&
              G.Here != &eastOfHouse && G.Here != &southOfHouse {
        switch G.ActVerb.Norm {
        case "find":
            if G.Here == &clearing {
                Printf("it seems to be to the west.\n")
                return true
            }
            Printf("it was here just a minute ago....\n")
            return true
        default:
            Printf("You're not at the house.\n")
            return true
        }
    } else {
        switch G.ActVerb.Norm {
        case "find":
            Printf("it's right here! Are you blind or something?\n")
            return true
        case "walk around":
            goNext(houseAround)
            return true
        case "examine":
            Printf("The house is a beautiful colonial house which is painted white. " +
                "it is clear that the owners must have been extremely wealthy.\n")
            return true
        case "through", "open":
            if G.Here == &eastOfHouse {
                if kitchenWindow.Has(FlgOpen) {
                    return moveToRoom(&kitchen, true)
                }
                Printf("The window is closed.\n")
                thisIsIt(&kitchenWindow)
                return true
            }
            Printf("I can't see how to get in from here.\n")
            return true
        case "burn":
            Printf("You must be joking.\n")
            return true
        }
    }
    return false
}

Compare this with the ZIL version from the second article:

<ROUTINE WHITE-HOUSE-F ()
  <COND (<EQUAL? ,HERE ,KITCHEN ,LIVING-ROOM ,ATTIC>
    <COND (<VERB? FIND>
      <TELL "Why not find your brains?" CR>)
      (<VERB? WALK-AROUND>
        <GO-NEXT ,IN-HOUSE-AROUND>
          T)>)
    (<NOT <OR <EQUAL? ,HERE ,EAST-OF-HOUSE ,WEST-OF-HOUSE>
            <EQUAL? ,HERE ,NORTH-OF-HOUSE ,SOUTH-OF-HOUSE>>>
      <COND (<VERB? FIND>
        <COND (<EQUAL? ,HERE ,CLEARING>
          <TELL "It seems to be to the west." CR>)
        (T
          <TELL "It was here just a minute ago..." CR>)>)
      (T <TELL "You're not at the house." CR>)>)
    (<VERB? FIND>
      <TELL
        "It's right here! Are you blind or something?" CR>)
    (<VERB? WALK-AROUND>
      <GO-NEXT ,HOUSE-AROUND>
    T)
  (<VERB? EXAMINE>
    <TELL
      "The house is a beautiful colonial house which is painted
      white. It is clear that the owners must have been
      extremely wealthy." CR>)
  (<VERB? THROUGH OPEN>
    <COND (<EQUAL? ,HERE ,EAST-OF-HOUSE>
      <COND (<FSET? ,KITCHEN-WINDOW ,OPENBIT>
        <GOTO ,KITCHEN>)
      (T
        <TELL "The window is closed." CR>
          <THIS-IS-IT ,KITCHEN-WINDOW>)>)
    (T
      <TELL "I can't see how to get in from here." CR>)>)
  (<VERB? BURN>
    <TELL "You must be joking." CR>)>>

If you squint a bit, the two versions aren’t that different. The control flow is the same; the nesting is the same. Go just uses curly braces instead of angle brackets and switch instead of nested COND expressions. That structural similarity made the translation process more mechanical than creative, which is exactly what you want when porting 15,000 lines of someone else’s code.2

Architecture

In the second article, I described Zork’s three building blocks: the parser, game objects, and the syntax system. The Go port mirrors the original architecture almost exactly. The differences are mostly in implementation, where Go’s type system and standard library let me simplify things.

The codebase is split into two packages:

gozork/
  engine/   # Reusable text-adventure engine (parser, objects, clock, I/O)
  game/     # Zork I game content (rooms, items, NPCs, action handlers)
  main.go   # Entry point

The engine package is completely game-agnostic. It knows nothing about trolls, grues, or the Great Underground Empire. It provides the parser, the object model, the action dispatch system, a clock for timed events, and I/O abstractions. All game-specific content lives in the game package and is injected into the engine at initialization time through registries and well-known object pointers.

This is the same split as in the original ZIL code, where the “g” files (gparser.zil, gmain.zil, etc.) had the generic engine logic, and the numbered files (1dungeon.zil, 1actions.zil) had the game-specific content. The difference is that in Go, this separation is enforced by the type system and package boundaries. In ZIL, it was just a naming convention.

The Game Object

In ZIL, a game object was defined using the OBJECT macro:

<OBJECT MAILBOX
  (IN WEST-OF-HOUSE)
  (SYNONYM MAILBOX BOX)
  (ADJECTIVE SMALL)
  (DESC "small mailbox")
  (FLAGS CONTBIT TRYTAKEBIT)
  (CAPACITY 10)
  (ACTION MAILBOX-F)>

In Go, it’s a struct literal:

mailbox = Object{
    In:         &westOfHouse,
    Synonyms:   []string{"mailbox", "box"},
    Adjectives: []string{"small"},
    Desc:       "small mailbox",
    Flags:      FlgCont | FlgTryTake,
    Action:     mailboxFcn,
    Item:       &ItemData{Capacity: 10},
}

The fields map one-to-one. The In pointer establishes the containment tree; the mailbox is inside the west-of-house room. Synonyms are the nouns that the parser will match. Flags are a uint64 bitfield. Action is a function that gets called when the player interacts with the object.

One idiomatic Go pattern I used here was optional facets using struct embedding.3 Not every object needs combat stats or vehicle properties, but in ZIL, every object had every property slot, whether it used them or not. In Go, I broke role-specific data into nil-able struct pointers:

type Object struct {
    // Core fields every object has
    Flags      Flags
    In         *Object
    Desc       string
    Action     Action
    // ...
 
    // Optional facets — nil unless needed
    *ItemData     // Size, Value, TValue, Capacity
    *CombatData   // Strength
    *VehicleData  // Type flags
}

An item like the brass lantern gets an ItemData facet with its size and value. The troll gets a CombatData facet with its strength. A room or a decorative object like the white house gets neither. The getters handle nil by returning zero-values, and the setters allocate the facet on first write, so the rest of the game code never has to worry about whether a facet exists before accessing it. This keeps the base Object struct lean while still allowing any object to acquire new capabilities when needed.

The Parser

The parser is by far the most complex part of the engine, clocking in at over 1,400 lines. Its job is the same as in the original: take a string like attack the nasty-looking troll with a rusty knife and produce three values: PRSA (the action/verb), PRSO (the direct object), and PRSI (the indirect object). In the second article, I complained about the original developers using four-letter abbreviations for three of the most important variables in the entire codebase. In Go, I at least gave them proper names. They’re still global state on the G struct, though, I couldn’t fix everything:

G.ActVerb   // the matched syntax (PRSA)
G.DirObj    // the direct object (PRSO)
G.IndirObj  // the indirect object (PRSI)

The parsing pipeline follows the same stages as the original:

Input

>attack the nasty-looking troll with a rusty knife
Tokenize() Split on whitespace and punctuation
Lex() Look up each token in the vocabulary
Clause() Group tokens into noun phrases
SyntaxCheck() Match against the syntax registry
SnarfObjects() Resolve token groups to Object pointers
ManyCheck() Reject "take all" where syntax disallows it
TakeCheck() Attempt implicit pick-up if needed

Output

G.ActVerb "attack" → vAttack
G.DirObj &troll
G.IndirObj &knife
The Go parser pipeline. Input goes through seven stages to produce the three game state variables.

One place where Go makes things easier is the vocabulary lookup. In ZIL, the vocabulary was a hand-built lookup structure. In Go, it’s a map[string]WordItem built at init time by scanning all syntax definitions, object synonyms, and adjectives:

Vocabulary = map[string]WordItem{
    "mailbox": {Types: WordObj},
    "small":   {Types: WordAdj},
    "attack":  {Types: WordVerb},
    "with":    {Types: WordPrep},
    "the":     {Types: WordBuzz},
    // ...hundreds more entries
}

A word can have multiple types. “Light” appears as a verb in the syntax table (SYNTAX LIGHT OBJECT ...) and as an object synonym for the brass lantern (SYNONYM LAMP LANTERN LIGHT). The parser has to figure out from context which one the player meant. In ZIL, this disambiguation was done by the WT? routine, which read raw bytes out of the vocabulary table and used bit masking to check what part of speech a word could be:

<ROUTINE WT? (PTR BIT "OPTIONAL" (B1 5) "AUX" (OFFS ,P-P1OFF) TYP)
    <COND (<BTST <SET TYP <GETB .PTR ,P-PSOFF>> .BIT>
           <COND (<G? .B1 4> <RTRUE>)
                 (T
                  <SET TYP <BAND .TYP ,P-P1BITS>>
                  <COND (<NOT <EQUAL? .TYP .B1>>
                         <SET OFFS <+ .OFFS 1>>)>
                  <GETB .PTR .OFFS>)>)>>

In Go, each vocabulary entry carries its types as a slice, so the same check is just calling Has():

type WordItem struct {
    Types WordTypes  // []WordTyp: WordVerb, WordAdj, WordObj, ...
    Norm  string
}

The Syntax

In the previous article, I showed how ZIL defines syntax entries:

<SYNTAX ATTACK OBJECT (FIND ACTORBIT) (ON-GROUND IN-ROOM)
    WITH OBJECT (FIND WEAPONBIT) (HELD CARRIED HAVE) = V-ATTACK>

In Go, this becomes:

{
    Verb:    "attack",
    Obj1:    ObjProp{
        HasObj:   true,
        ObjFlags: FlgSearch | FlgPerson,
        LocFlags: LocSet(LocInRoom, LocOnGrnd),
    },
    ObjPrep: "with",
    Obj2:    ObjProp{
        HasObj:   true,
        ObjFlags: FlgSearch | FlgWeapon,
        LocFlags: LocSet(LocHeld, LocCarried, LocHave),
    },
    Action: vAttack,
},

It’s more verbose. The ZIL version is arguably cleaner; the syntax definition reads almost like a grammar rule: verb, object with flags, preposition, object with flags, action. ZIL could get away with this because the compiler knew about the syntax system and could pack it all into a compact table format. In Go, there’s no such compiler magic, so each constraint has to be spelled out as struct fields. The trade-off is that Go’s explicit approach is easier to maintain long-term.4 You don’t need a ZIL manual open to understand what FlgSearch | FlgPerson means, whereas (FIND ACTORBIT) requires you to know that ACTORBIT maps to “person” and that FIND is a search scope modifier.5

The verb handler itself maps cleanly:

func vAttack(arg ActionArg) bool {
    if !G.DirObj.Has(FlgPerson) {
        Printf("I've known strange people, but fighting a %s?\n", G.DirObj.Desc)
        return true
    }
    if G.IndirObj == nil || G.IndirObj == &hands {
        Printf("Trying to attack a %s with your bare hands is suicidal.\n",
            G.DirObj.Desc)
        return true
    }
    if !G.IndirObj.IsIn(G.Winner) {
        Printf("You aren't even holding the %s.\n", G.IndirObj.Desc)
        return true
    }
    if !G.IndirObj.Has(FlgWeapon) {
        Printf("Trying to attack the %s with a %s is suicidal.\n",
            G.DirObj.Desc, G.IndirObj.Desc)
        return true
    }
    return heroBlow()
}

Compare it with the ZIL version from the previous article:

<ROUTINE V-ATTACK ()
  <COND (<NOT <FSET? ,PRSO ,ACTORBIT>>
    <TELL
     "I've known strange people, but fighting a " D ,PRSO "?" CR>)
   (<OR <NOT ,PRSI>
       <EQUAL? ,PRSI ,HANDS>>
    <TELL
     "Trying to attack a " D ,PRSO " with your bare hands is suicidal." CR>)
   (<NOT <IN? ,PRSI ,WINNER>>
    <TELL "You aren't even holding the " D ,PRSI "." CR>)
   (<NOT <FSET? ,PRSI ,WEAPONBIT>>
    <TELL
     "Trying to attack the " D ,PRSO " with a " D ,PRSI " is suicidal." CR>)
   (T
     <HERO-BLOW>)>>

Same logic, same output. If you played the original, you’d recognize every response.

Making It Idiomatic

I could have translated the 15,000 lines of ZIL code into Go line-for-line, and it would have worked, but it would have been ugly Go. The ZIL codebase was written in 1981. The language itself wasn’t primitive; MDL, its parent, literally stands for “More Datatypes than Lisp” and came with lists, vectors, associations, and strings as built-in types. But the Z-machine it targeted was. Everything had to fit in kilobytes of memory, so the data structures were deliberately compact: flat byte and word tables searched linearly, fixed-size property slots, and manual memory management. Go has better alternatives for most of these.

Maps Instead of Lookup Tables

In ZIL, the vocabulary, syntax definitions, and object search all used flat table structures that were iterated sequentially. The Z-machine had opcodes for table manipulation (GETB, PUTB, GET, PUT), but the access pattern was always linear: walk the table, compare each entry. This was perfectly fine for Zork since the game only had around 200 objects and a vocabulary of roughly 600 words, nowhere near enough for sequential iteration to cause any noticeable delay. In Go, these become hash maps:

// Vocabulary: O(1) word lookup instead of scanning a table
Vocabulary map[string]WordItem
 
// Actions: O(1) verb-to-handler mapping
G.Actions map[string]VerbAction
 
// Clock functions: O(1) event handler lookup
G.ClockFuncs map[string]func() bool

The performance difference doesn’t matter for a text adventure game; the original ran fine on a 4 MHz processor, but the code is much easier to read. Instead of find this word in the table by scanning each entry, it’s look up this word.

Slices Instead of Linked Lists

The ZIL object tree used a linked-list structure to track children. Each object had a FIRST pointer to its first child, and each child had a NEXT pointer to its sibling. Traversing all items in a room meant walking a linked list.

In Go, children are simply a slice:

type Object struct {
    In       *Object    // parent
    Children []*Object  // children — just a slice
    // ...
}

Moving an object looks like this:

func (o *Object) MoveTo(dest *Object) {
    o.Remove()
    o.In = dest
    dest.Children = append(dest.Children, o)
}

No pointer surgery, no sibling chains. It just works.

Interfaces for Testability

Another pattern I found useful was using interfaces for anything that needed to be swapped during testing. The game’s random number generator, for instance:

type RNG interface {
    Intn(n int) int
}

In production, this is backed by Go’s math/rand seeded with the current time. In tests, it’s a deterministic source seeded with a fixed value. The same pattern applies to I/O:

type GameState struct {
    GameOutput io.Writer  // os.Stdout in production, bytes.Buffer in tests
    GameInput  io.Reader  // os.Stdin in production, strings.Reader in tests
    Rand       RNG
    // ...
}

This made the game fully testable without any mocking libraries or build flags.

Dot-imports for Readability

I also made a slightly unconventional choice and used dot-imports in the game package:

import . "github.com/ajdnik/gozork/engine"

This lets the game code reference engine types and functions without a package prefix, so instead of engine.G.Here, engine.Printf(...), engine.Perform(...), I can write G.Here, Printf(...), Perform(...). This makes the game code read closer to the original ZIL, where there was no package system. It’s a pattern most Go style guides warn against,6 but in this case, it serves the project well since the game package is the only consumer of the engine package, and the improved readability of the game logic is worth the trade-off.

The Standard Library Did Most of the Work

Beyond maps and slices, there are places where Go’s standard library takes care of things that the ZIL code had to manage by hand.

Take the clock system. ZIL’s CLOCKER managed timed events and daemons in a single fixed-size table of 180 bytes. Interrupts grew inward from one end, daemons from the other, with two global pointers (C-INTS and C-DEMONS) tracking where each region ended. Adding an event meant walking the table entry by entry, comparing routine pointers, and manually adjusting the boundary index. The CLOCKER routine itself iterated the table with raw byte offsets (C-INTLEN stride of 6 bytes per entry), decrementing tick counters and calling routines through APPLY:

<ROUTINE CLOCKER ("AUX" C E TICK (FLG <>))
     <SET C <REST ,C-TABLE <COND (,P-WON ,C-INTS) (T ,C-DEMONS)>>>
     <SET E <REST ,C-TABLE ,C-TABLELEN>>
     <REPEAT ()
             <COND (<==? .C .E>
                    <SETG MOVES <+ ,MOVES 1>>
                    <RETURN .FLG>)
                   (<NOT <0? <GET .C ,C-ENABLED?>>>
                    <SET TICK <GET .C ,C-TICK>>
                    <COND (<0? .TICK>)
                          (T
                           <PUT .C ,C-TICK <- .TICK 1>>
                           <COND (<AND <NOT <G? .TICK 1>>
                                       <APPLY <GET .C ,C-RTN>>>
                                  <SET FLG T>)>)>)>
             <SET C <REST .C ,C-INTLEN>>>>

In Go, I kept the same logical structure because the behavior had to match, but the bookkeeping mostly disappeared. Each event is a named struct with a Key, Tick, and Fn field. Finding an event is a string lookup instead of a byte-offset walk. The Clocker function still iterates the array and decrements ticks, but the code reads like what it does rather than how memory is laid out:

func Clocker() bool {
    if G.ClockWait {
        G.ClockWait = false
        return false
    }
    end := G.QueueDmns
    if G.ParserOk {
        end = G.QueueInts
    }
    flg := false
    for i := len(G.QueueItms) - 1; i >= end; i-- {
        if !G.QueueItms[i].Run || G.QueueItms[i].Tick == 0 {
            continue
        }
        G.QueueItms[i].Tick--
        if G.QueueItms[i].Tick <= 0 && G.QueueItms[i].Fn() {
            flg = true
        }
    }
    G.Moves++
    return flg
}

The save/restore system tells a similar story. In ZIL, SAVE and RESTORE were single Z-machine opcodes. The interpreter would dump or reload the entire game memory in one shot. The game code itself was trivial:

<ROUTINE V-SAVE ()
     <COND (<SAVE>
            <TELL "Ok." CR>)
           (T
            <TELL "Failed." CR>)>>

<ROUTINE V-RESTORE ()
     <COND (<RESTORE>
            <TELL "Ok." CR>
            <V-FIRST-LOOK>)
           (T
            <TELL "Failed." CR>)>>

Two routines, four lines of logic each. All the heavy lifting happened inside the Z-machine interpreter itself.

In Go, there’s no virtual machine to lean on, so the save system has to explicitly capture and restore every piece of mutable state. The captureState function walks all 250 game objects and records their positions, flags, and properties into a struct. Then it copies every game-specific flag (troll defeated, low tide active, match count, and dozens more) along with the clock queue and score. The whole snapshot gets serialized to disk using encoding/gob:

func doSave() error {
    fname := promptFilename("save")
    s := captureState()

    f, err := os.Create(fname)
    if err != nil {
        return fmt.Errorf("create save file: %w", err)
    }
    defer f.Close()

    return gob.NewEncoder(f).Encode(s)
}

Restoring is the reverse: decode the file, walk the objects again, write everything back. The captureState and applyState functions together are around 200 lines, mostly because every game flag has to be explicitly listed. It’s tedious but straightforward, and encoding/gob handles all the serialization, so I never had to think about byte layouts or endianness.

By the Numbers

The finished port is roughly 16,000 lines of Go across 29 source files, with another 7,700 lines of tests in 41 test files. The engine package, the game-agnostic parser and runtime, is about 3,000 lines. The remaining 13,000 lines are all Zork-specific: room definitions, item definitions, action handlers, verb implementations, and syntax data.

The game world has 250 game objects: 110 rooms (21 on the surface, 69 underground, and 20 in the maze), 122 items and characters, and 18 global objects like the grue, the player’s hands, and pronoun sentinels. There are 130 unique verb words mapped to 53 normalized actions through 378 syntax definitions. The buzz word list, which is the articles, fillers, and connectors the parser ignores, contains 21 entries, and the synonym table maps over 100 word variations to their canonical forms.

The whole project has zero external dependencies. The go.mod file contains exactly two lines: the module path and the Go version. Everything is built using only the Go standard library. This was expected going in. Zork is a 1980s text game built on simple data types and straightforward logic. No networking, no graphics, no concurrency. The kind of data structures it needs (maps, slices, string manipulation, file I/O) is exactly what modern standard libraries are good at. Between io, strings, math/rand, encoding/gob, and testing, Go covered everything.

When compiled, the binary is a single static executable of a few megabytes. The original Z-machine story file for Zork I was around 85 kilobytes, but needed a separate interpreter to run.7 The Go binary contains both the “interpreter” (engine) and the “story file” (game) compiled together, plus the entire Go runtime. But there’s nothing to install. Download the binary, run it, and you’re standing in an open field west of a white house.

Memory usage at runtime is a few megabytes, mostly the Go runtime itself. The actual game state, 250 objects, the parser’s token buffers, and the clock queue are negligible. The original Zork I, after being compressed through the Z-machine, fit into around 80 kilobytes of memory on a TRS-80 with 16 KB of available RAM. Modern machines have orders of magnitude more resources than those early home computers,8 and that abundance has made us lazy about memory and CPU utilization. When you have gigabytes of RAM, nobody optimizes for kilobytes anymore.

Version Control and Distribution

The biggest difference between this port and the original isn’t in the code itself; it’s in how the code is managed.

The original Zork developers stored their code on mainframe disk packs and later on floppy disks. There was no version control system in the modern sense. As I showed in the second article, they tracked changes using comments in the source code: ; "next added 1/2/85 by JW" and ; "Changed 6/10/83 - MARC". That was their commit log.

When Infocom was acquired by Activision and eventually shut down,9 the source code was considered lost. The floppies sat in boxes, forgotten, for decades. It wasn’t until 2019 that historicalsource released the ZIL source code on GitHub,2 giving the public access to Zork’s internals for the first time since the 1980s. Without that release, this port wouldn’t exist.

The GoZork repository, by contrast, has lived on GitHub from day one. Every change is tracked in Git with meaningful commit messages. The project uses a CI/CD pipeline via GitHub Actions that runs on every push and pull request:

on:
  push:
    branches: [main]
  pull_request:
    branches: [main, "feature/**"]
 
jobs:
  lint:    # golangci-lint
  vet:     # go vet static analysis
  test:    # tests with coverage report

Every commit is automatically linted, vetted for common mistakes, and tested. The pipeline generates a coverage summary and reports it in the GitHub Actions UI. If a test fails, the exact failure is surfaced in the pull request, down to which command produced unexpected output.

Go’s compilation model also makes distribution easy. Since go build produces a static binary with no runtime dependencies, distributing the game is just compiling it for each target platform. Cross-compilation is a one-liner:

GOOS=darwin  GOARCH=arm64 go build -o gozork-macos
GOOS=linux   GOARCH=amd64 go build -o gozork-linux
GOOS=windows GOARCH=amd64 go build -o gozork.exe

Three commands and you’ve got binaries for macOS, Linux, and Windows. No installer, no please install Java 17 first dialogs. Compare that to what the original Infocom team had to do: write a separate Z-machine interpreter in platform-specific assembly for every platform they wanted to support.10 Between 1980 and 1985, they built interpreters for the TRS-80, Apple II, Commodore 64, IBM-PC, Atari ST, Amiga, and over a dozen more. Each one meant learning a new CPU’s assembly language (Z80, 6502, 8088) and working within that machine’s memory constraints. By 1985, they switched to writing interpreters in C just to keep up. Today, a Go compiler flag handles it.

Testing

This is where I think modern tooling makes the biggest difference. The Infocom team tested their games by hand.11 A core group of in-house testers, along with a network of outside volunteers, would sit down, play through the game, try different commands, look for bugs, and check that the output was correct. For a game with hundreds of rooms, items, and interaction combinations, that was exhausting. And it was never going to be complete; there’s no way a human tester can cover every possible input sequence.

The Go port uses automated playthrough tests. A test feeds a sequence of commands into the game and asserts that the output contains (or doesn’t contain) specific strings:

type Step struct {
    Command  string   // command to type
    Contains []string // substrings that MUST appear in the response
    Excludes []string // substrings that must NOT appear
}
 
func TestPlaythroughOpening(t *testing.T) {
    steps := []Step{
        {Command: "open mailbox", Contains: []string{"leaflet"}},
        {Command: "take leaflet", Contains: []string{"Taken"}},
        {Command: "read it", Contains: []string{"ZORK"}},
        {Command: "drop it", Contains: []string{"Dropped"}},
        {Command: "south", Contains: []string{"South of House"}},
        {Command: "east", Contains: []string{"Behind House"}},
        {Command: "open window", Contains: []string{"open"}},
        {Command: "in", Contains: []string{"kitchen"}},
        {Command: "west", Contains: []string{"Living Room"}},
    }
    runScript(t, steps)
}

The runScript helper initializes a fresh game state, replaces stdin/stdout with in-memory buffers, injects a deterministic random number generator (seeded with a fixed value so combat outcomes are reproducible), feeds all the commands, captures the output, and then checks each step’s assertions against the corresponding output segment.

This approach makes it possible to write a full playthrough test, a single test that plays through nearly the entire game from start to finish, collecting all 19 trophies and depositing them in the trophy case. The test is over 500 lines of commands and assertions. Running it takes seconds. A human playtester would need hours.

The deterministic RNG is what makes this work. The original Zork has random elements: combat outcomes, the thief’s movements, and the probability-based inventory fumble I described in the second article. By injecting a seeded RNG, these all become predictable. The troll always takes the same number of hits to defeat. The thief always moves to the same rooms. The fumble check always passes or fails the same way. This makes the tests reproducible across runs, machines, and Go versions.

The engine’s I/O abstraction is what makes all of this possible:

// In production
G.GameOutput = os.Stdout
G.GameInput  = os.Stdin
 
// In tests
var out bytes.Buffer
G.GameOutput = &out
G.GameInput  = strings.NewReader("open mailbox\ntake leaflet\n")
G.Rand       = rand.New(rand.NewSource(1))

The game code doesn’t know or care whether it’s writing to a terminal or a buffer. It calls Printf(...), and the output goes wherever the writer points. This feels obvious in hindsight, but I’m glad I set it up early because retrofitting it later would have been painful.

After every test run, the game state is restored to its initial snapshot using ResetGameState() and ResetObjectTree(). This means no test can pollute another’s world state, which was a problem I ran into early on when tests would pass individually but fail when run together.

Closing Thoughts

When I started this project, I thought I was building a Kubernetes test workload. Instead, I spent months reading 1980s LISP code, learning Go, and porting a game that was written on a $400,000 mainframe to run on my laptop. The original was stored on floppies that were lost for decades. Mine lives on GitHub. The original was tested by someone sitting down and playing through the game. Mine has a CI pipeline that does it in seconds.

But the game itself? The parser still breaks input into verb, direct object, and indirect object. Objects still form a containment tree. Actions still cascade through a priority chain. The troll still blocks the passage. The thief still steals your treasures. And typing attack the mailbox with the elvish sword still gets you the same response it did in 1981:

>attack the mailbox with the elvish sword
I've known strange people, but fighting a small mailbox?

Some things don’t need improving.

Footnotes

  1. Fandom. (April 23, 2026). Implementors https://zork.fandom.com/wiki/Implementors

  2. historicalsource. GitHub. (April 23, 2026). Zork I source code https://github.com/historicalsource/zork1 2

  3. Go Documentation. Effective Go: Embedding https://go.dev/doc/effective_go#embedding

  4. Rob Pike. Gopherfest SV. (November 18, 2015). Go Proverbs: “Clear is better than clever” https://www.youtube.com/watch?v=PAAkCSZUG1c&t=875

  5. S. W. Galley & Greg Pfister. (February 1979, revised November 1979). ZIL: Zork Implementation Language. Collected in Jeff Nyman’s zmachine repository https://github.com/jeffnyman/zmachine

  6. Go Wiki. GitHub. (April 23, 2026). Go Code Review Comments — Import Dot https://go.dev/wiki/CodeReviewComments#import-dot

  7. Marc S. Blank & S. W. Galley. How to Fit a Large Program into a Small Machine http://mud.co.uk/richard/htflpism.htm

  8. A TRS-80 in 1980 had 16 KB of RAM and a Z80 processor running at 1.77 MHz. A typical 2024 laptop has 16 GB of RAM and a multi-core CPU running at 3+ GHz, roughly a million-fold increase in memory and a thousand-fold increase in clock speed, not counting parallelism. For the broader trend, see Transistors per microprocessor https://ourworldindata.org/grapher/transistors-per-microprocessor

  9. H. Briceno, W. Chao, A. Glenn, et. al. Infocom. (December 15, 2000). Down From the Top of Its Game: The Story of Infocom, Inc.

  10. Graham Nelson. The Z-Machine Standards Document: Appendix D. A short history of the Z-machine https://inform-fiction.org/zmachine/standards/z1point1/appd.html

  11. Jimmy Maher. The Digital Antiquarian. (March 20, 2013). The Top of its Game https://www.filfre.net/2013/03/the-top-of-its-game/