- Sun 16 August 2026
- 29 min read
- Linux
- #rust, #roguelike, #gamedev, #ratatui, #ecs, #legion, #procedural-generation, #field-of-view, #pathfinding, #serde, #ron, #terminal

I have wanted to write a roguelike since roughly the first time ADOM killed me in the Small Cave. A few months ago I finally started one, mostly because I wanted a Rust project that was large enough to be interesting and small enough to finish a piece of it in an evening.
The result is crawlfest: a turn-based ASCII roguelike that runs in a real terminal. Procedurally generated multi-floor dungeons, permadeath, field of view, monsters that pathfind to you, loot, equipment, status effects, and a tombstone at the end. The setting is a fantasy world with a wrecked starship scattered through its lower levels, which is why the dungeon contains both kobolds and hyposprays.
This is not a release announcement. The game is unfinished, the source is not public, and I might publish it at some point. What follows is a write-up of the technical groundwork that already exists, because the architecture came together cleanly enough to be worth documenting: the framework choices, the map generation, the field of view implementation, the ECS layout, and the parts I got wrong on the first attempt.
Table of Contents
- Table of Contents
- Why Rust, and why not a game engine
- Architecture in one paragraph
- The map
- Map generation
- Field of view
- The camera
- Monsters that think
- Combat rules as pure functions
- Content lives in RON, not in Rust
- Status effects
- Save games without serialising the ECS
- Testing something this random
- What is next
Why Rust, and why not a game engine
The project started as a Rust learning exercise, so the language was the point rather than a decision. The interesting decisions were everything around it.
A roguelike in a terminal needs surprisingly little: a way to paint characters into a grid, a way to read keys, random numbers, and two classic algorithms (field of view and pathfinding). It does not need a renderer, a physics engine, an asset pipeline, or a scene graph. Bevy or Godot would have been a lot of machinery around a game whose entire visual output is a few thousand coloured characters.
The stack I ended up with:
| Concern | Choice |
|---|---|
| Language | Rust, edition 2024 |
| Terminal UI and input | ratatui 0.30 with crossterm 0.29 |
| ECS | legion 0.4 |
| Roguelike algorithms | bracket-pathfinding, bracket-random |
| Data-driven content | serde and ron |
Everything is pure Rust. cargo build needs no system libraries, which matters more than it sounds like, as the next section explains.
The bracket-lib detour
The obvious starting point for a Rust roguelike is bracket-lib, the library that accompanies Herbert Wolverson’s book Hands-on Rust. It bundles terminal emulation, field of view, pathfinding, dice, and a colour system. I planned to use it and abandoned it within an hour.
The problem is packaging. bracket-terminal 0.8.7 does offer several backends, including a cross_term one that draws into the terminal you are already in. But winit is not an optional dependency of the crate, so you pay for the graphical stack whichever backend you select. Here is the reverse dependency chain, in the direction cargo tree -i prints it, from the offending system library up to the crate that pulled it in:
servo-fontconfig-sys <- servo-fontconfig <- crossfont <- sctk-adwaita <- winit <- bracket-terminal
On Linux that means system fontconfig, freetype and X development packages, plus a CMake policy workaround, in order to build a game that draws ASCII. The default backend is OpenGL, which also opens a graphical window that emulates a terminal rather than using the terminal I am already sitting in.
So I split the difference. The rendering and input layer became ratatui plus crossterm, which are pure Rust, build in seconds, and paint into the actual terminal. The algorithms came from bracket-pathfinding and bracket-random, which are standalone sub-crates with no GUI dependencies at all. I get Wolverson’s well-tested shadowcasting and A* implementations without any of the packaging pain, and the game runs in Konsole, tmux, or over SSH like a roguelike should.
Architecture in one paragraph
Entities are bundles of components. Behaviour lives in systems, which are functions over component queries. Shared singletons such as the map and the message log live in legion::Resources. Content lives in RON files. Adding a feature is almost always a new component plus a system, or a new RON entry, and rarely a change to existing code.
That last sentence is the actual design goal. A hobby project that gets touched in bursts has to be additive, otherwise every session starts with an hour of remembering how things fit together.
/// Where an entity sits on the map, in grid cells.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Position {
pub x: i32,
pub y: i32,
}
/// How an entity is drawn, and who wins when several share a tile.
#[derive(Clone, Copy, Debug)]
pub struct Renderable {
pub glyph: char,
pub fg: Color,
pub render_order: i32,
}
/// A marker component: no data, it just tags an entity as a monster.
#[derive(Clone, Copy, Debug)]
pub struct Monster;
The player is an entity with Position, Renderable, CombatStats, Attributes and Level. A monster is the same minus Level, plus Monster and BlocksTile. An item on the floor has a Position; the same item in your backpack has InBackpack instead. Picking something up is literally swapping one component for another.
The turn state machine
Roguelikes are turn-based, which in code means the world only advances when the player acts. That is one enum:
AwaitingInput -> PlayerTurn -> MonsterTurn -> AwaitingInput
RunState holds those three plus every screen that is really just a different input mode: the main menu, name entry, the load menu, the inventory, the drop menu, look mode, the cheat menu, and the game-over screen. The main loop redraws every iteration, then either waits for a key (input states) or runs systems and immediately advances (turn states).
Redrawing between the two turn states is a small detail with a large effect on feel: you see your own move land before the monsters answer it, instead of both happening in the same frame.
PlayerTurn counts the turn, resolves melee, removes the dead, and recomputes field of view. MonsterTurn runs the AI, resolves melee again, removes the dead, ticks status effects, and removes the dead once more, because poison can kill after the fight that inflicted it is over.
The map
The map is the least clever part of the codebase and deliberately so:
pub struct Map {
pub width: i32,
pub height: i32,
pub tiles: Vec<TileType>,
pub revealed: Vec<bool>,
pub visible: Vec<bool>,
}
A 2D grid stored as one flat vector in row-major order. It is cache-friendly, trivial to clone, and trivial to serialise into a save file. One helper converts coordinates into an index, and after that nobody in the codebase writes y * width + x by hand:
pub fn xy_idx(&self, x: i32, y: i32) -> usize {
(y * self.width + x) as usize
}
A fresh map is entirely wall. Generators carve floor into a solid block of rock, which makes “the default is wall” a convenient invariant: anything a generator forgets to touch stays safely solid.
Two traits, and other people’s algorithms work on my data
This is the part of the design I like most, and it is very Rust. I do not inherit from a framework map class. I implement two traits on my own type, and the library’s algorithms start working on it.
BaseMap answers three questions: is this tile opaque, what are its walkable neighbours and at what cost, and what is the heuristic distance between two tiles.
impl BaseMap for Map {
fn is_opaque(&self, idx: usize) -> bool {
self.tiles.get(idx).is_none_or(|t| *t == TileType::Wall)
}
fn get_available_exits(&self, idx: usize) -> SmallVec<[(usize, f32); 10]> {
let mut exits = SmallVec::new();
let x = idx as i32 % self.width;
let y = idx as i32 / self.width;
for (nx, ny) in [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)] {
if !self.is_blocked(nx, ny) {
exits.push((self.xy_idx(nx, ny), 1.0));
}
}
exits
}
fn get_pathing_distance(&self, idx1: usize, idx2: usize) -> f32 {
// straight-line distance: a good guess on open floor
}
}
impl Algorithm2D for Map {
fn dimensions(&self) -> Point {
Point::new(self.width, self.height)
}
}
That is the entire integration surface. is_opaque gives me shadowcasting field of view. get_available_exits plus get_pathing_distance give me A*. And because my own map generators reuse get_available_exits for their connectivity checks, “reachable during generation” and “walkable during play” can never drift apart. There is exactly one definition of what counts as a step, and everything else asks it.
Movement is cardinal only, so the exits list is at most four entries. SmallVec keeps those inline on the stack instead of heap-allocating for every node A* expands.
Map generation
Levels are built by anything implementing one method:
pub trait MapBuilder {
fn build(&mut self, rng: &mut RandomNumberGenerator) -> BuiltMap;
}
pub struct BuiltMap {
pub map: Map,
pub player_start: Position,
pub spawn_regions: Vec<SpawnRegion>,
pub flavour: Option<&'static str>,
}
There are four implementations today, and the floor decides which one it wants at runtime through a Box<dyn MapBuilder>.
The contract that keeps the spawner shape-agnostic
The first version of the spawner took a list of rooms. That worked fine until the second generator produced caves, which have no rooms, and the third produced one enormous hall, which has exactly one.
The fix was to change what generators hand back. A SpawnRegion is just a group of walkable tiles, and the contract is that region 0 contains the player’s start, so the spawner skips it and you never materialise nose to nose with a Borg drone. A rooms level reports one region per room. A cave or a prefab level dices its open floor into rectangular chunks of roughly room size and reports those. The spawner does not know or care which it got: it walks the regions, rolls a monster for some of them, and places it.
flavour is an optional line for the message log, so a distinctive level style can announce itself in the game’s voice when you arrive.
Rooms and corridors
The classic, and still the backbone of the game. Try to place thirty random rectangles, keep each one only if it does not intersect an already placed room, and connect each new room to the previous one with an L-shaped tunnel, flipping a coin for which leg comes first so the result does not look too regular.
Two details are worth noting. First, carve_room floors the cells inside the rectangle, which leaves a one-tile rock wall between rooms that happen to sit next to each other. Second, the carving core takes a bounds rectangle:
pub(super) fn rooms_and_corridors(
map: &mut Map,
rng: &mut RandomNumberGenerator,
bounds: Rect,
) -> Vec<Rect>
Every carved tile stays strictly inside those bounds. That single parameter is what makes the hybrid generator further down possible, because it can run the whole classic algorithm on half a map and trust that nothing leaks into the other half.
Caverns via cellular automata
Organic caves, in three moves.
Noise. Fill the interior with random rubble, roughly 55 percent floor. The border stays solid so nothing can leak off the map edge.
Smoothing. Apply a local rule five times: a tile becomes wall if five or more of the nine cells in its neighbourhood (itself plus its eight neighbours) are wall, otherwise floor. Each pass erodes lone pillars and fills lone potholes, and static dissolves into caves. This is a cellular automaton in the Game of Life sense: same machinery, different rule.
for _pass in 0..5 {
let mut smoothed = Map::new(self.width, self.height);
for y in 1..self.height - 1 {
for x in 1..self.width - 1 {
let mut walls = 0;
for dy in -1..=1 {
for dx in -1..=1 {
if map.tile(x + dx, y + dy) != Some(TileType::Floor) {
walls += 1;
}
}
}
if walls < 5 {
smoothed.set_floor(x, y);
}
}
}
map = smoothed;
}
The double buffer matters. Each pass reads the old map and writes a fresh one. Mutating in place lets early writes in a pass influence later reads, which smears the caves diagonally. I know this because my first version did exactly that and produced caves that all leaned the same way.
Connectivity. A cellular automaton reliably produces one big cave plus a handful of sealed pockets. I flood-fill from every not-yet-seen floor tile using a breadth-first search, keep the largest connected area, and fill every unreachable pocket back in with rock. Everything you can see, you can walk to.
The same BFS then places the stairs on the reachable tile farthest from the entrance, measured in steps rather than straight-line distance. That one line is what makes a shapeless cave play like a journey instead of a lucky spawn.
One BFS helper serves three jobs across the whole codebase: connectivity checks, farthest-tile queries, and the tests.
pub fn bfs_distances(map: &Map, start: Position) -> Vec<Option<u32>> {
let mut dist = vec![None; (map.width * map.height) as usize];
let start_idx = map.xy_idx(start.x, start.y);
dist[start_idx] = Some(0);
let mut queue = VecDeque::from([start_idx]);
while let Some(idx) = queue.pop_front() {
let here = dist[idx].expect("queued tiles always have a distance");
for (next, _cost) in map.get_available_exits(idx) {
if dist[next].is_none() {
dist[next] = Some(here + 1);
queue.push_back(next);
}
}
}
dist
}
Cellular automata occasionally roll a cramped dud, so generation is generate-and-check: accept the first candidate whose main cave covers a quarter of the map, and after ten attempts take whatever came out, because a small cave still plays.
Prefabs: levels drawn by hand
Some places should not be random. A set piece is designed, it is the same every visit, and that is the point. So it lives as a text drawing in assets/prefabs/:
######################
#....................#
#..#....#....#....#..#
#....................#
#..#....#....#....#..#
+..................>.#
#..#....#....#....#..#
#....................#
#..#....#....#....#..#
#....................#
######################
The glyph language is close to what the renderer shows: # wall, . floor, > down staircase, @ the player start, + the vault’s single doorway, and a space meaning “leave whatever the map already had”, which lets vaults have irregular edges.
The files are pulled in with include_str!, so they are embedded at compile time. That is a deliberate contrast with the RON content files, which are read at runtime specifically so I can rebalance a monster without recompiling. Monster stats are balance data. A prefab is level structure, which is code by another name, and embedding it means a missing or broken drawing is a compile or test failure instead of a panic three floors into a run.
Two things consume prefabs. PrefabLevelBuilder turns one drawing into a whole fixed level (the Big Room, a NetHack homage: one vast hall, nowhere to hide, for anyone). HybridBuilder stamps one into a corner of an otherwise procedural map.
Hybrid levels, and a test that proves a design claim
Depth 3 is the level I am happiest with. The west half is the ordinary rooms-and-corridors algorithm, confined to a bounds rectangle. The east half is the temple vault stamped into untouched rock. Between the two sits a two-column moat of guaranteed rock, and exactly one L-shaped corridor connecting a room to the vault’s single + doorway. The stairs are inside the vault, so to go deeper you have to walk into the set piece.
+--------------------------------+------------+
| rooms & corridors (random) : ######### |
| : #.......# |
| [room]----[room] : +...>...# |
| | ^................:..^######## |
| [room] '-- the ONE corridor |
+--------------------------------+------------+
“Exactly one way in” is a design claim, and design claims rot. So it is a test. The vault sits against the east edge behind the moat, which means every path into it must cross the column just west of its wall. Counting the walkable tiles in that column has to yield exactly one:
let vault_x = 78 - Prefab::parse(TEMPLE).width - 1;
let crossings = (0..map.height)
.filter(|&y| !map.is_blocked(vault_x - 1, y))
.count();
assert_eq!(crossings, 1, "expected exactly one corridor into the vault");
Because generation is random, the test runs several seeded iterations and asserts invariants rather than any particular shape. The cavern test does the same: every walkable tile must be reachable from the entrance, and every cave must have stairs.
Which generator does a floor use
fn builder_for_depth(depth: i32, rng: &mut RandomNumberGenerator) -> Box<dyn MapBuilder> {
match depth {
1 => Box::new(SimpleMapBuilder::new(MAP_WIDTH, MAP_HEIGHT)),
3 => Box::new(HybridBuilder::temple(MAP_WIDTH, MAP_HEIGHT)),
5 => Box::new(PrefabLevelBuilder::big_room()),
_ => {
if rng.range(0, 2) == 0 {
Box::new(SimpleMapBuilder::new(MAP_WIDTH, MAP_HEIGHT))
} else {
Box::new(CavernBuilder::new(MAP_WIDTH, MAP_HEIGHT))
}
}
}
}
Floor 1 is always the familiar rooms, because a first impression should be legible. Floors 3 and 5 are fixed landmarks, so runs share a shape of memory. Everything else is a coin flip between rooms and caves.
Field of view

The screenshot above is really a screenshot of the FOV implementation. Bright grey walls and floor are what the player can see right now. The dim blue-grey structure below is what the player remembers from earlier. Everything else is black, because it has never been seen.
Those are exactly the two boolean vectors on the map:
revealed[i]: has the player ever seen tilei? It accumulates and is never cleared, and it lives as long as the level does.visible[i]: can the player see tileiright now? Cleared and recomputed on every move.
The geometry itself is symmetric shadowcasting from bracket-pathfinding, which works on my map because of the is_opaque implementation shown earlier. My own FOV module is a dozen lines of glue:
pub fn update_fov(map: &mut Map, origin: Position, range: i32) {
map.clear_visible();
let center = Point::new(origin.x, origin.y);
for pt in FieldOfViewAlg::SymmetricShadowcasting
.field_of_view(center, range, &*map)
{
if map.in_bounds(pt.x, pt.y) {
let idx = map.xy_idx(pt.x, pt.y);
map.visible[idx] = true;
map.revealed[idx] = true;
}
}
}
Writing this myself would have meant implementing octant transforms and slope bookkeeping, which is a well-solved problem I had no interest in solving again. Delegating the geometry and owning the two flags is the right split.
Choosing the algorithm explicitly matters more than it looks, and I got this wrong at first. The crate’s bare field_of_view function is not the symmetric implementation. It is an alias for the older recursive one, kept as the default for backwards compatibility, and the symmetric variant has to be asked for by name. I had written “symmetric shadowcasting” in a comment and then called the function that does not do it, which is an easy mistake to make and an invisible one to play, because both algorithms produce a perfectly plausible-looking lit area. It only matters because of what the next section does with the result.
The &*map in there is a small piece of borrow-checker choreography that is worth understanding: it reborrows the &mut Map as the shared reference field_of_view wants. The borrow ends when the call returns its owned vector, which leaves the function free to mutate the map immediately afterwards.
Field of view as a game rule
Once you have visibility as data, other systems can just read it, and two of them do.
The renderer picks per tile between full colour, a dim remembered blue, and nothing at all. Entities are only drawn if they stand on a currently visible tile, so no monster is ever visible lurking in the dark.
The monster AI uses FOV symmetry for aggro. Field of view is mutual: if the player can see the tile a monster is standing on, that monster can see the player. So the entire aggro check is one array lookup:
if !map.visible[idx] {
continue;
}
This is cheap, it needs no per-monster FOV computation, and it has a pleasant side effect: nothing shuffles around off-screen. The dungeon only comes alive where you are looking at it, which is both a performance property and a design one.
The inference is only sound if visibility really is mutual, which is the whole reason the algorithm choice above is not a detail. Symmetric shadowcasting guarantees it for transparent tiles, and a monster always stands on a walkable, non-opaque tile, so the case the AI asks about is exactly the case the guarantee covers. With the recursive algorithm the rule was quietly approximate: a creature could aggro from a tile it could not actually see you from, or fail to notice you from one it could. That is now a test, which seeds a room full of pillars and asserts that every visible floor tile can see the origin back. It fails immediately if anyone swaps the call back to the convenient default.
The camera
The map is 78 by 40, usually larger than the visible terminal area, so the renderer draws a window into it. The camera holds exactly one piece of state, the world coordinate of the viewport’s top-left corner, and everything else is two coordinate conversions.
fn new(map: &Map, view_w: i32, view_h: i32, focus: Position) -> Self {
Self {
left: (focus.x - view_w / 2).clamp(0, (map.width - view_w).max(0)),
top: (focus.y - view_h / 2).clamp(0, (map.height - view_h).max(0)),
}
}
Subtracting half the viewport puts the focus dead centre, and the clamp pulls the window back onto the map near the edges. Clamping rather than showing void beyond the border is the classic roguelike feel: the view stops scrolling when you approach an edge and your @ drifts off centre. The .max(0) handles maps smaller than the viewport, where clamp’s upper bound would otherwise fall below its lower bound and panic.
The map layer is drawn by walking screen cells and asking the camera which world tile each one shows. Going in that direction means touching exactly the cells that fit on screen and never iterating over the parts of the map that are scrolled out of view. Entities go the other way, from world to screen, because there are few of them and most are off-screen.
Drawing happens in three layers: map, then entities sorted by render_order descending so the lowest number is painted last and ends up on top, then the look cursor. The player is order 0 and can therefore never be hidden by anything.
Monsters that think
Monster AI is a three-phase pass, and the phases exist because of the borrow checker rather than in spite of it.
Read. Collect every monster, its position, and whether it is confused. Build a set of occupied tiles (all monsters plus the player) and a map from tile to occupant.
Decide. Borrow the map immutably. Each monster either attacks (Manhattan distance 1 from the player), or paths one step closer with A*, or stumbles randomly if it is confused. Nothing is mutated here, only decisions collected into vectors.
Write. Drop the map borrow, then apply. Attackers get a WantsToMelee component, movers get their Position updated, and the occupancy set is kept in sync as moves land so two monsters cannot claim the same tile within one turn.
let path = a_star_search(idx, player_idx, &*map);
if path.success && path.steps.len() > 1 {
let next = path.steps[1];
// steps[0] is the monster's own tile
}
Note that the AI never applies damage. It only tags an attacker with an intent component, and a separate resolve_melee pass turns intents into damage and log lines. That is the classic ECS intent pattern, and it pays off immediately: the player’s bump attack, a monster’s attack, and a confused monster stumbling into another monster all produce the same intent and are resolved by the same code path. Monster versus monster brawls came out of that for free, which is the sort of thing that makes an ECS worth the ceremony.
Combat rules as pure functions
The combat module has no ECS types in it at all. It is arithmetic, which makes it trivially testable.
The model is a light d20 system in ADOM’s clothing. Every combatant has an attack rating and a defense value. A swing rolls 1d20 and connects if the roll plus attack reaches the defense value, with a natural 1 always missing and a natural 20 always hitting, so no fight is ever a mathematical certainty.
pub fn hit_connects(roll: i32, att: i32, dv: i32) -> bool {
match roll {
1 => false,
20 => true,
_ => roll + att >= dv,
}
}
Taking the roll as a parameter instead of an RNG reference is what makes this a pure function of its inputs, and the tests can then probe every edge exactly instead of rolling dice and hoping.
Damage is dice notation, the lingua franca of the genre, parsed from strings like 2d6+1 into a three-integer Copy struct. Degenerate dice such as 0d6 are rejected at parse time, and the RON loader validates every dice string in the content files at startup, before the terminal goes into raw mode. A typo in the bestiary fails with a readable message on a normal screen rather than a panic behind an alternate screen buffer.
Content lives in RON, not in Rust
Monsters and items started as Rust source. They are now data:
(
name: "Giant Newt",
glyph: 'n',
color: "lightgreen",
max_hp: 3,
att: 1,
dv: 8,
damage: "1d3",
xp: 3,
danger: 1,
weight: 10,
poison_turns: Some(3),
poison_damage: Some("1d2"),
description: "A slick, twitching amphibian the size of a house cat. Slow, stupid, and entirely willing to bite the hand that pokes it, and its bite festers.",
),
RON is a good fit here because it reads almost like Rust literals, Option fields survive round trips honestly, and it supports comments, so the bestiary carries its own field documentation. serde‘s derive does all the parsing. Optional effect fields default to None, so a monster that just bites stays a short block.
The split I try to hold: data is stats, flavour and spawn weights; logic is behaviour. Adding a creature is editing a text file. Adding a new kind of behaviour still needs Rust, specifically a new component plus the system that acts on it, plus a new optional RON field to switch it on.
Danger instead of depth cutoffs
The first spawn table had hard depth bands, and it felt exactly as mechanical as it sounds. Now every monster has a baseline danger, meaning the depth it is at home at, and each encounter rolls its own target around the floor’s depth:
let mut target = depth + rng.roll_dice(2, 3) - 4;
if rng.roll_dice(1, 20) == 20 {
target += 3; // the dungeon's occasional cruel joke
}
The 2d3 - 4 term is a wobble of plus or minus two around the floor’s depth, and every monster within two points of the resulting target is eligible, weighted by its own commonness scaled by how close its danger sits to the target. Creatures fade in above their home depth and fade out below it instead of appearing and disappearing at a boundary, and one encounter in twenty gets an extra nudge of plus three toward the deeper end of the bestiary.
Note that the wobble and the eligibility spread stack, so out-of-depth encounters do not need the nudge at all: on floor 2 the target can already reach 4, which makes a danger 5 mimic eligible on its own. The nudge just widens the tail. A mimic on floor 2 is unfair, survivably so, and produces a story. That is the whole design goal of the game in one function.
Below the deepest creature in the bestiary, the window comes up empty, and the deepest monsters hold the fort rather than leaving an eerily peaceful abyss. A gap in the middle of the table stays a quiet spawn, though, because falling back there would unleash the worst things far too early.
Status effects
A status is a small Copy component with a countdown, ticked once per round. Poisoned rolls fresh damage each round and can kill, and the tombstone will name the poison. Confused randomises movement instead of honouring it.
Two rules made confusion actually interesting rather than annoying:
A stumble always costs the turn, including one that lurches into a wall. Otherwise confusion is nearly free in a corridor, where three of four directions are wall, and you could simply re-press until the roll went your way, so it would never tick down.
A stumble that lands on somebody is a swing at them, whoever they are. That keeps confusion from being a “monster switched off” button, and it means a confused monster will brawl with its neighbours. Monster versus monster fights are only narrated when the player can see them.
The afflicted entity carries the status (Poisoned), and whatever inflicted it carries a matching ability component (InflictsPoison), which is the same split as an item’s ProvidesHealing versus the hit points it actually restores. Both come from optional RON fields, so a venomous bite is data.
Save games without serialising the ECS
Legion worlds are full of runtime-only things: entity ids, component storage layouts, archetypes. Serialising all of that is painful and mostly meaningless across runs.
But the game is data-driven, and that turns out to solve the problem. Every monster and item is spawned from a template. So the only facts a save needs are which template, where, and what has changed since spawning, plus the map, what has been explored, and the player’s own sheet.
Loading is then just replaying spawns: look each template up, override the changed bits. The file is small, human-readable RON, immune to entity-id churn, and honestly fun to peek at.
The trade-off, stated plainly, is that anything not captured is forgotten. There is a test called saving_captures_what_loading_needs that acts as a tripwire. It is also the source of the most instructive bug in the project so far: the tripwire only catches what it exercises. When I added status effects, save and load silently dropped them, and the test kept passing, because it never set up a poisoned hero in the first place. A test that asserts on the facts it already knows about is blind to a new kind of mutable state until somebody adds it to the setup.
Permadeath is enforced at the file level. The save is deleted the instant the player dies, before the tombstone is even drawn, so quitting the process at the death screen resurrects nobody.
Testing something this random
There are 62 unit tests, and cargo test runs them in well under a second because none of them need a terminal.
The categories that earned their keep:
Pure rules. Dice parsing, to-hit edges, attribute modifiers, healing that tops up but never overheals. Cheap and exhaustive.
Invariants over random output. Generators cannot be asserted against a fixed shape, so the tests seed the RNG, generate several levels, and check properties: every walkable tile is reachable, stairs exist, region 0 holds the player start, exactly one corridor enters the vault.
That style of test has a blind spot, and the cavern generator found it for me. Its retry loop generated up to ten candidates, kept the first one whose main cave covered a quarter of the map, and pruned each candidate’s sealed pockets as part of judging it. But when all ten failed, the loop generated one more map and returned it without ever pruning it. The connectivity guarantee therefore had a hole in exactly the path that runs when generation has been unlucky. The existing test never noticed, because on a full-size 78 by 40 level the retry budget is never exhausted. Reproducing it took a cramped 20 by 10 map, where it shows up in roughly one run in two hundred. The fix restructured the loop so every candidate, including the last-resort one, is pruned exactly once before it can be returned, and the regression test pins a seed that used to come back with an unreachable pocket in it.
Compile-time content validation. Because prefabs are embedded with include_str!, the test that parses them is the file validation. Break a drawing and the build’s test run fails, long before a player sees it.
Regressions of subtle rules. A confused lurch into a wall still costs the turn. An unconfused wall bump does not. God mode declines death. Dying deletes the save file.
Rendering logic without rendering. The header line is a pure function that formats the HUD into a string, so a test can assert that status tags survive an 80-column terminal, where ratatui truncates an over-wide title on the right. Afflictions sit directly after the hit points precisely because that is where they will not be cut off exactly when the player most needs to read them.
One more small thing I enjoy: the cheat menu, opened by typing iddqd in the dungeon, exists only in debug builds via cfg!(debug_assertions). Unlike a C preprocessor block, the disabled code still has to compile in release mode, so it cannot quietly rot the way an untested #ifdef branch can. Release builds ship honest, and cheats never persist into a save file.
What is next
The backlog is long and deliberately additive: item identification with blessed, uncursed and cursed states, ranged combat, a hunger clock, shops, bosses, more map algorithms (BSP, drunkard’s walk, mazes), and a builder chain so a single floor can mix strategies rather than picking one. There is a full setting document behind all this, six dungeon strata with their own ecologies and factions, and six possible endings, none of which is implemented yet.
Whether any of that ships publicly is an open question. The groundwork, though, is the part I actually wanted to write about: an ECS that makes features additive, two trait implementations that hand me field of view and pathfinding on my own data type, four map generators behind one interface, content that lives in text files, and a save format that sidesteps the hardest serialisation problem by storing facts instead of objects.
Rust turned out to be a very good fit for this. Not because of performance, which is irrelevant when the frame budget is “whenever the player presses a key”, but because an enum you must match exhaustively, a borrow checker that pushed me into a clean read, decide, write structure for the AI, and traits as the interface to other people’s algorithms are all exactly the tools a slowly growing hobby codebase needs.
Comments
You can use your Mastodon or other ActivityPub account to comment on this article by replying to the associated post.
Search for the copied link on your Mastodon instance to reply.
Loading comments...