Zaytun Project

The Zaytun Language

Zaytun is a small programming language with one purpose: writing text-based games. It is not a general-purpose language with a game library bolted on. Rooms, items, characters, conversation and combat are part of the grammar.

What the language is for

Most text-adventure tools are either enormous systems with a long apprenticeship, or thin wrappers over a general-purpose language where a room is a dictionary and an exit is a string key. Zaytun takes the middle path. A room is a room. An exit is an exit. A conversation topic is a topic. The things a game is made of are the things the language has words for.

The practical result is that a game reads like a description of itself. That matters for a writer working alone, and it matters more when you are listening to your own source code rather than scanning it — there is little punctuation noise and no ceremony to wade through between one meaningful line and the next.

What it looks like

Here is a complete, working fragment: one room, one item in it, and something that happens the first time the player arrives.

A room, from Ragnarök.
room landvidi "Landvidi" { description when shoe_layers >= 7:
    "Your hall, and on the bench the shoe, finished, thick as a book."
description:
    "Your hall: a long room hung with green growing branches instead of
     shields, which the others find funny and which suits you."

exit east -> idavoll item iron_shoe

on enter {
    if not shoe_started {
      set shoe_started = true
      print "(TAKE SHOE. Then COBBLE.)"
    }
} }

A room can have as many description when lines as it needs. They are checked in order, the first true one wins, and the plain description is the fallback. That single feature does most of the work of making a world feel like it changes: the same room, written twice, once before an event and once after.

Anything the built-in commands do not cover, you add as a verb of your own. Verbs are not faked by putting a magic object in every room; they are declared once and decide for themselves what they mean wherever the player is standing.

A player command that does not exist until you write it.
verb cobble { aliases: "sew", "stitch", "mend"

if not has(iron_shoe) {
    print "You have nothing to work on."
} else {
    set shoe_layers = scraps
    print "You work the strips in, one over the last."
} }

People are declared where they stand, and a conversation is a set of topics reached with ask someone about something. Because a topic body is ordinary statements, a conversation can change the world rather than just print at the player.

A character with a state-dependent greeting and two topics.
npc heimdall "Heimdall" { aliases: "heimdall", "watchman", "warden"

dialogue: if horn_blown
    then "\"Blown,\" says Heimdall. \"Nine worlds heard it.\""
    else "\"I hear the grass,\" says Heimdall, not looking at you."

on talk {
    if naglfar_loose and not horn_blown {
      play the_horn
      set horn_blown = true
      move heimdall to vigrid_field
    }
}

topic "bridge", "bifrost" {
    print "\"It will not hold them. It was never going to hold them.\""
    set wisdom = wisdom + 1
}

otherwise { print "Heimdall goes on listening to something far away." } }

And this is what a player hears when they run the compiled result:

A few turns of play in the terminal runtime.
Landvidi Your hall: a long room hung with green growing branches
instead of shields, which the others find funny and which suits you. You see here: the iron
shoe, a strip of hide. Exits: east.

> take shoe Taken: the iron shoe.

> cobble You work the strips in, one over the last. *** 7 layers. It will not fold for anything. ***

> east Iðavöllr The green plain at the middle of Asgard, where
the gods hold their assembly. Exits: north, west, south, east.

Language features

The world

People and creatures

Story and structure

The compiler

zaytunc is written in C11 and depends on nothing but a C compiler and a standard library. It reads Zaytun source and writes a bytecode file. It does not play games, and neither player can compile one.

It behaves like any other compiler: diagnostics on standard error with a file and line number, and a nonzero exit code on failure, so it drops straight into a Makefile or a continuous integration job.

A compile that fails, and what it says.
$ zaytunc mygame.zaytun zaytunc: mygame.zaytun:14: room "cellar" refers to
undeclared room "catacombs"

That message is the point of a whole compiler stage. Before a single byte is emitted, every cross-reference in the game is resolved: every exit target, every item placed in a room, every enemy spawned, every scene played, every character moved. A typo in a room name is a build error, not a dead end a player discovers an hour in.

The stages are one file each:

Lexer
Source text to tokens.
Parser
Tokens to a syntax tree, and resolution of include.
Validation
Every cross-reference checked, and every duplicate declaration caught.
Code generation
The syntax tree to the binary bytecode format.

The two runtimes

A compiled game is played by a runtime, and there are two. They are separate programs in separate languages, and either will play any compiled game.

The terminal player

zaytunplay is C11, like the compiler, and reads and writes plain text on a terminal. For a screen reader that is about as good as it gets: no canvas, no layout, no focus management, no live regions to fight with — just a stream of text and a prompt.

It can also be driven non-interactively, one command per argument or one per line on standard input. That is what makes automated testing of a game possible: a playthrough is a file of commands and a transcript you can compare.

Three ways to run the same game.
zaytunplay game.zaytunb                        # interactive zaytunplay game.zaytunb
-c "north" -c "look"   # run commands and exit zaytunplay game.zaytunb < script
# the same, from a file

The same engine also builds as a shared library, with output captured into a string instead of printed, for embedding a Zaytun game inside another program.

The browser player

The browser player is a page you open. There is no server, no build step, no bundler and no network access: it runs straight off a local file. You load a compiled game with a file picker and play it in the page.

It is also hosted online, ready to use with nothing installed. It is the same page either way, and it still has no compiler and fetches nothing: a game you load stays on your own machine, and the page keeps working with the network off.

It is written as plain scripts rather than modules on purpose, because modules are blocked by browser security rules when a page is opened from a local file, and requiring every player of every game to start a web server first is a worse trade than a shared global scope.

Saving

Both runtimes read and write the same save format. A save carries the state of the random number generator as well as the state of the world, so a seeded game resumes exactly where it left off — and a save made in the terminal can be finished in a browser, or the reverse.

The bytecode format

Compiling produces a single .zaytunb file: a four-byte marker, a format version, a string pool with every repeated string stored once, and tagged instructions with variable-length integers. It is lightly scrambled so that a compiled game is not casually readable — which is obfuscation, not encryption, and the documentation says so in those words rather than implying more.

The format version is a hard gate. A runtime that meets a file it does not know how to read refuses it and says so, instead of guessing and producing a subtly wrong game. The cost is that the compiler and both runtimes are updated together; the benefit is that a mismatch is an error message rather than a mystery.

Documentation

The language guide is a single long document that ships with the compiler. It covers getting started, a first game written from scratch, splitting a game across files, the complete language reference, playing a compiled game, and what the compiler's error messages mean.

Every example in it compiles. They are extracted from the document and run as part of checking the toolchain, because documentation that has drifted from the language is worse than none.

Read the Zaytun language guide.

The ecosystem

Each piece is a project of its own, so you can take only the part you need.

zaytunc
The compiler, and the home of the language guide. C11, no dependencies.
zaytun-cli
The terminal player, zaytunplay. Also builds as an embeddable shared library, and carries the cross-runtime check.
zaytun-player
The browser player. Open the page, load a game, play — locally, or at the hosted copy.
Five complete games
An adventure, a roguelike, two long story games and a detective mystery — each its own repository, each buildable with one command.

Development philosophy

Two runtimes, held identical

Having two implementations of one language is a liability unless they agree. So they are tested against each other: the same seeded game is played through both, and the transcripts are compared line by line. They match exactly, or the check fails.

This has already earned its place. It caught a memory bug in the C runtime that silently skipped the first room's arrival handler — invisible in one game, fatal in another, and the sort of thing that could have gone unnoticed for a year.

Fail at build time, not at play time

Anything the compiler can catch, the compiler catches: undeclared rooms, items placed in two places at once, a verb colliding with a built-in command, an item marked as starting open that has no way to be opened. A player should never be the one to find a typo.

No dependencies

The compiler and terminal player need a C compiler. The browser player needs a browser. That is the entire list. Nothing to install, nothing to keep up to date, and nothing that can stop working because a package was pulled.

Documentation that is executed

Examples in the guide are compiled and run rather than trusted. If the language changes under a documented example, the check fails.

Plain text as the interface

Every part of this toolchain speaks plain text, both because that is what a text game is made of and because plain text is the most accessible interface there is. It works with a screen reader, a braille display, a terminal, and a pipe. This is the practical end of the idea on the Why Zaytun Project? page.