Appendix: the bytecode format
You do not need any of this to write a game. It is here because it is the one part of the system that otherwise lives only in source comments, and because zaytunc --version now reports a format number, which makes it something a user can reasonably ask about.
The shape of a file
"ZYTN" 4 bytes, magic
version 1 byte — the format number
capabilities varint — what the game can do; HELP is built from it
poolCount varint — how many strings follow
strings poolCount × (varint length, then that many bytes of UTF-8)
the game rooms, items, npcs, enemies, scenes, verbs and handlers,
as a tagged-opcode encoding of the compiler's trees
Whole-file XOR obfuscation is applied last, over everything including the magic.
Integers are unsigned LEB128: seven bits per byte, low byte first, high bit set on every byte but the last. Strings are interned — a string used in twenty rooms is stored once and referred to by its index in the pool.
Expressions and statements are written as a prefix walk of the tree, each node introduced by a one-byte tag (0x01 a number, 0x02 a string, 0x03 a boolean, 0x04 a flag reference, and so on). An optional expression is written as tag 0x00, which is how a missing dark if or a topic with no when costs one byte.
Versions, and what a player will accept
A player refuses a file from a newer compiler, because that is the only case where it genuinely cannot know what the bytes mean. It also refuses one older than its minimum, which zaytunplay and the browser both report as a file that is too old, naming the floor and telling you to recompile.
This compiler writes format 16 and the players read 16. Everything added during the 1.1.0 cycle — tables, growable flags, free: as an expression, trunc(), a write that reaches into a value, reset turn, bare if, reset elapsed — went into 16, rather than each addition raising the number. One release, one number.
bare if is the one that number does not cover, and it is worth knowing why. It puts a new expression on the vehicle record, which moves that record's shape, so format 16 means two shapes: one with the field and one without. A vehicle game compiled by an earlier 1.1.0 download is read by a later player as truncated bytecode or unknown expression tag — a corruption message for a file that is only older, and the version gate cannot refuse it, because the file says 16 and 16 is what the player reads. Recompiling the game is the whole of the fix. Everything published — the games on the site, all eight archives, both players — was rebuilt together and agrees.
The two statement tags, reset turn and reset elapsed, are worth a note, because they are the case this scheme handles least well. Each is one new statement tag. A tag an older 16 file never contains cannot change how that older file reads, so nothing compiled at 16 had to be rebuilt — the seventeen games on the site are byte-for-byte what they were, and that is the difference between this and bare if above, which moved a record's shape and did break files. But a file that does use one of these statements still calls itself 16, so a reader built before the tag existed accepts it at this gate and only then meets a statement it has no case for. The gate can only refuse what admits to being newer.
reset turn shipped with the format, which is the easy case. reset elapsed did not: it was added after the 1.1.0 archives were published, and kept at 16 rather than opening a 17, the same call bare if got and for the same reason — one release, one number, and everything the project publishes rebuilt together so that it agrees. The cost is paid by anyone holding an earlier 1.1.0 download who is handed a game that uses the statement: they get unknown statement tag where a raised number would have said the file is newer and named the fix. Upgrading is the whole of it, and no game already compiled is affected. Adding a statement to a format that is out in the world is what the number is for, and this project has twice decided that a release is the unit that number counts.
Compare 15 → 16, which was not additive at all: several records changed shape rather than merely growing — a proc's parameter list, a flag's slot count, a room's item placements, the enemy series table — and a reader with no branch for the old shape cannot recover, which is why formats 11 to 15 are gone.
Note what a format number does not carry: the meaning of an operator lives in the compiler's output, not in the player. When / stopped truncating, a game compiled before the change went on behaving exactly as it had, because the truncation was already baked into what the compiler wrote. It is the recompile that changes a game, which is why that entry in the changelog says to put trunc() where a whole number was meant.
The floor is maintained by hand and is a claim about the readers, so it is worth stating what lowering it would mean: every read added since that format has to branch on version, in both runtimes, identically. Until that work is done the floor stays where it is.
The suite tests the claim with a real artefact, because it cannot be tested any other way: the version tests stamp a different version byte into a current file, which produces a mislabelled current file — the readers have no version branches, so they read it perfectly whatever the byte says. zaytunplay/testdata/format16.zaytunb is a game compiled by the released zaytunc 1.1.0 and checked in beside its source. While the floor is 16 or lower the suite requires it to play; the day the floor rises above 16 the same check flips on its own and requires the "too old" answer instead, and specifically not a complaint that the file is damaged.
That fixture answers a second question as well, and it is the sharper one. A format number only means something if the bytes under it hold still — 16 shipped once and then grew inside its own number, and the shipped player did not refuse the new file, it crashed on it. So while the compiler still writes 16, recompiling that source must reproduce that file byte for byte. Changing what the writer emits fails the check, and the two honest answers are to keep the record's shape or to bump the version.
The authoritative layout is the pair of readers and writers themselves — zaytunc/bytecode.c and zaytun-web-player/bytecode.js — and they are checked against each other on every build.
On the obfuscation, honestly
The XOR is not security. It is there so that a compiled game is not readable in a text editor, or greppable for spoilers.
Be clear-eyed about what that does and does not buy you. The browser has to decode the format in order to play the game, so the decoder and the key necessarily ship in zaytun-web-player/bytecode.js, in plain view. That stops someone dragging your file into an editor and reading the ending; it is not encryption and it is not DRM, and anyone willing to read that file can reconstruct the format.
There is no way around this for something that runs entirely in the player's browser. If you need to protect a game's content for real — a competition where players should not see the answers ahead of time, say — that needs a server-side component, which this project does not have.