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.
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.
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.
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:
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
- Rooms with conditional descriptions, and exits that can be locked behind any condition with a custom refusal message.
-
Darkness.
dark ifmakes a room's contents genuinely unknowable while the condition holds — not merely undescribed. You cannot take, examine or talk to what you cannot perceive. Your inventory and the exits stay available, because you can feel those out by hand. - Items with aliases, examine text, readable text, and handlers for taking, dropping, using, opening, closing and reading.
- Containers. An item can hold other items, and can be locked, so what is inside is genuinely out of reach until it is opened.
- Weapons and armour are ordinary items with a damage or defence value.
People and creatures
- Characters whose greeting is an expression, so what somebody says depends on the state of the game, plus a handler that runs when you talk to them.
-
Conversation topics reached with
ask, with a catch-all for anything you did not anticipate. Topic bodies are statements, so branching dialogue is just flags. - Movement. Characters are not fixed to a room; you can send them elsewhere, and ask where they are, so they can walk a route on the turn clock whether or not the player is watching.
- Enemies and combat with health, attack and defence, ambushes on entry, a handler that runs every round, and one that runs on defeat.
- A bestiary. Enemies declared at the top level exist as templates and are placed at runtime, with their statistics rolled at the moment they appear.
Story and structure
- Scenes — named blocks of narration, played at the moment the story calls for them rather than tied to a location. Their paragraphs are expressions, so narration can read the state of the game.
- Flags holding true or false, a number, or a string.
- A turn clock. The turn count is readable in any expression, and a top-level handler runs after every turn, which is where anything that ticks lives.
- Randomness — dice, percentage chances, and picking one of several phrasings so repeated text does not read like a loop. A seed makes a whole game reproducible.
-
Verbs of your own, with aliases, and access to what the player typed, including a
preposition and a second object, so
unlock the door with the keyis a command you can write. -
Multiple files.
includesplices one source file into another, so a large game is laid out however suits it. Each of the story games on this site is nine files: an entry point that declares the world, and eight more it pulls in.
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.
$ 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.
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.