#2 A world, saves, replays, and a bug hiding in the save format
· Nick
Since the first entry the core has grown a world to put things in, files to keep it in, and a way to prove that two runs agree. Still no graphics and no characters. Most of this entry is plumbing, and one part of it is a bug that turned out to be worth the story.
A world made of tiles
The world is a grid of 1×1 m tiles on discrete levels, numbered from zero up. A wall or a door is a tile; a staircase will be a special link between two levels, but stairs are not built yet. Internally the world is a set of flat layers (floor, structure, structure flags) for the whole map, plus a derived layer for walkability. The map is tracked in 16×16 chunks with version counters, so that a renderer or a path search can ask “did this area change?” without comparing tiles.
The size of the world is chosen when it is created. Building is done by commands that take a rectangle: place a floor, a wall or a door over an area, or remove them. If part of the rectangle is blocked, the rest is still built and the command reports what it skipped. For now there are three built-in tile kinds: floor, wall, door. The map is part of the world hash and of saves from here on. It is checked against a slow, obviously-correct reference model on thousands of random commands.
Saves and replays
A save and a replay share one file container. It is split into labelled sections, each with a type and a version, so new parts of the world (characters, needs, relationships) can become new sections later without breaking old files. There are checksums over the header and the body, the body is compressed, and every size is checked against a limit before any memory is allocated, so a corrupt or hostile file cannot make the game eat all your RAM. Tile kinds are stored by name rather than by number, so a save keeps working if the list of kinds changes.
A replay is what I wanted from the start: a seed plus the command log. While recording, the core also writes a control hash every 100 ticks. When playing a replay back, it compares each command’s result and each control hash along the way and stops at the first tick where they disagree. A replay also remembers which build of the core recorded it, so a replay from a different build is refused rather than silently diverging.
The core itself never touches files: it reads and writes streams, and the game decides where saves live. That boundary is enforced by the same banned-API machinery as before.
Not done yet: a command-line tool that plays back a replay file, and the game side of saving (a save folder, autosaves, safe writes).
Proving two runs agree
The determinism test now runs one scenario in two separate processes: a seed, a script of build commands (some of them deliberately invalid) and a test system that pulls from every random stream. Each process runs it three ways — straight through, through a save and restore, and through a replay of the recorded bytes — and compares hashes on every tick. The two processes then compare their hashes against each other every 10th tick.
I broke it on purpose to be sure it can fail. An unseeded Random smuggled into the tick turned the test red. A randomized string hash, which .NET seeds differently in every process, went unnoticed inside one process and was caught only when comparing two. That is the whole reason for two processes.
The rule I wrote down is that one build of the core must give the same hashes on x64 and arm64 processors, so a replay recorded on a Mac with an Apple chip has to play on a Windows PC. Pinned reference hashes and example save and replay files exist for exactly that check. Floating-point trigonometry and exponentials differ between platforms, so they are off limits in anything that affects the world; I will need a deterministic replacement the first time a system needs them. These reference files change only deliberately: if a hash drifts, the change has to be explained before it is accepted.
A benchmark that can say no
There is now a headless runner that takes a scenario, a seed and a number of ticks, and prints a JSON report: median and 99th-percentile time per tick, the number of agents, the world hash, and how many bytes were allocated per tick. The first scenario builds a 512×512 map over 16 levels with about 52,000 build commands.
The baseline is kept per machine, because timings from a laptop mean nothing on a desktop. A regression is a median more than 10% above baseline or a 99th percentile more than 35% above, and only if the increase is larger than 0.05 ms, since an almost-empty tick sits at the resolution of the timer. One gate holds on every machine: zero bytes allocated per tick. I should be honest about what this measures today. With no systems and no characters a tick takes about 35 nanoseconds, so the time gate is mostly asleep until the first real systems arrive in the movement milestone. The allocation gate is the one doing work now.
The flaky test that wasn’t flaky
While building the benchmark, a save-format test started failing on some builds even though the core had not changed. The test flips each byte of a compressed replay in turn and requires the loader to refuse every one. On one build, a byte near the end of the file was accepted. On the previous build, the same test passed.
The reason it came and went: a replay carries the identifier of the build that wrote it, so the compressed bytes differ from build to build, and with them the byte that gets flipped. The hole was always there; the test only noticed it when the dice came up that way.
The hole itself: the checksum covered the data after decompression, and .NET’s decompressor turned out to be forgiving. It accepts a stream that never says it has ended, and ignores anything after the end. Flipping a padding bit in the last byte gives a different, still valid stream that decompresses to the same data, so the checksum still matched and a damaged file loaded as good.
The fix is a new container version. The checksum now covers the bytes as they are stored in the file and is checked before decompression, and decompression must end exactly where the header says. An empty body is stored uncompressed, since the compressor writes nothing for empty input. The checks now use fixed compressed streams that do not depend on the build, plus fuzzing of damaged files. The example files were regenerated for the new version; the world hashes did not change.
Elsewhere
The website, the one you are reading, now shows two visitor counters on the front page: unique visitors of all time and of the last 24 hours. They are counted from the web server’s page log, with no JavaScript, no cookies and no outside services. An address is stored only as a salted hash, bots are filtered by their user agent, and the numbers lag by a few minutes. Several people behind one network count as one.
Next
The first milestone, the core skeleton, is finished. Next is M1, the Godot building view: walls, doors and rooms from the world model, floor slicing, a camera that rotates in 90° steps and plain cubes instead of art. Work on it has not started yet. See the roadmap.