nihilurk
nihilurk is a free, open-source Rogue-like (roguelike) for the terminal, written in Rust. A Rogue retroclone about descending thirteen floors, taking the Element of Yoord off the Dungeon Lord, and carrying it back up. Life is unfair and death is permanent. Terminal only, one binary, no assets, a lot of punch.
If you’re enjoying nihilurk, buy me a coffee.
What you get:
- A classic roguelike on a turn-based grid: thirteen floors, traps, gear of unknown quality, and permanent death. There is one save, and it is deleted when an expedition ends.
- Auto-explore (
o) and auto-fight (Tab), so safe ground is quick and the fights are the game. - Trick shots, the thing nihilurk does that Rogue never did: shoot a trap, a coin or a potion on the floor from across the room and it goes off over everything around it. Bursts chain. Only ranged attacks do it, and it catches you too if you stand close.
- Seeded floors (
-s 1234), and two ways down: nihil, who carries gear, or the lurk (-b lurk), who has claws and fur. - Four languages, English, Portuguese, Spanish and Haitian Creole, one binary each, picked at build time.
Download a prebuilt game (v0.6.0), no Rust needed:
- Windows, x86_64 (.zip)
- macOS, Apple Silicon (.tar.gz)
- Linux, x86_64 (.tar.gz)
- Linux, aarch64 (.tar.gz)
Unpack it and run ./nihilurk (nihilurk.exe on Windows). All four languages are in the download: ./nihilurk --lang pt picks one (en, pt, es, ht), and with no flag it follows your LC_ALL, LANG or LANGUAGE. Keep the files together: the dispatcher starts the nihilurk-<lang> binary next to it. The binaries are not signed, so macOS quarantines a browser download: xattr -d com.apple.quarantine nihilurk* clears it. Every file has a checksum at the same address plus .sha256, and older versions are on the releases page.
Or install from crates.io (needs Rust 1.91 or newer) and play:
cargo install nihilurk # install the English game
cargo install nihilurk --force --no-default-features --features lang-pt # or lang-es, lang-ht
nihilurk # play
A crates.io install holds one language, and --lang does nothing there. Run the install line again with another lang-* to switch. The prebuilt download is the way to keep all four.
If the shell says nihilurk: command not found, the cargo bin directory is not on your PATH. rustup normally adds it, but a rebuilt dotfile or a distro Rust package may not. Add it, then open a new shell:
# bash: append to ~/.bashrc zsh: append to ~/.zshrc
export PATH="$HOME/.cargo/bin:$PATH"
# PowerShell on Windows, once (it sticks for your user)
[Environment]::SetEnvironmentVariable("Path", "$env:USERPROFILE\.cargo\bin;" + [Environment]::GetEnvironmentVariable("Path", "User"), "User")
Until then, ~/.cargo/bin/nihilurk (on Windows %USERPROFILE%\.cargo\bin\nihilurk.exe) runs it.
From a clone:
cargo run -p nihilurk # play
cargo run -p nihilurk -- -s 1234 # play a specific seed
cargo test # everything
The game needs a terminal of at least 80 columns by 25 rows. In a smaller one the message log draws garbled letters. That is a known limit and will not be fixed.
It runs on Windows: a tester built it there and played it. On macOS the prebuilt binary is built and started by CI (Apple Silicon), but nobody has played it there yet. If you try Windows or macOS, open an issue and say what happened, good or bad.
Where things are
MANUAL.md how to play: controls, combat math, items, monsters.
the site https://bae-alves.github.io/nihilurk/, the manual and docs/, built from this repository.
doc/nihilurk.6 the command reference. `man nihilurk` works from the prebuilt tarball (it ships `nihilurk.6`) and the AUR package; `cargo install` installs the binary only, so use `nihilurk -h` or `man ./doc/nihilurk.6` from a clone.
docs/ how to add content to the game. Start at docs/README.md.
CONTRIBUTING.md reporting bugs, sending patches and translations, and the LLM rule.
gdd.md what nihilurk is trying to be. Design, not code.
models/ the game: rules, content tables, ECS systems.
strings/ every player-facing sentence, one file per language.
engine/ the terminal front end: input, rendering, the loop.
particle-core/ the particle arithmetic, `no_std` and dependency-free.
compat/ the machine matrix. Does it still run on a Pi?
compat/ is a test rig, kept out of the workspace’s default members, so a bare cargo build or cargo test never compiles it:
./compat_test.sh # nihilurk, built and checked on six machines
./docs_style.sh # docs/, held to the house style
./aur_check.sh # aur/PKGBUILD, against the tag it pins and this working copy
Once per clone, git config core.hooksPath .githooks turns on a pre-commit check that runs docs_style.sh and refuses a commit that changes the turn schedule or an intent queue without touching a page under docs/. It also names the pages to update when you add a ring, a flag, a key or the like, and never refuses for that – see .githooks/pre-commit.
Adding content
There is no pink dragon in nihilurk. The whole point is putting one in.
Open models/src/monsters.rs, find BESTIARY, add a line:
MonsterDef::row("pink dragon", 'D', Color::Magenta, Chase,
8, 12, 2, 10, 2, 7)
.grants(&[Grant::of::<FireImmune>()]),
That is: name, glyph, colour, how it moves, then hit points, attack die, attack bonus, armour die, armour bonus, and the shallowest floor it can appear on – plus, chained on, the fact that fire does nothing to it.
cargo build
That is the change. The pink dragon now populates floors from depth 7, is something a wand of polymorph can turn a foxbat into, something a scroll of create monster can summon, a legal bane for a vorpal weapon, and it comes back correctly out of a save file. Nothing else in the codebase had to be told it exists, because nothing else enumerates species.
Items are the same shape, in models/src/catalog.rs – one table per kind, one row per thing:
WeaponDef::new("quarterstaff", Color::DarkYellow, 7).missile(6),
ArmorDef { name: "brigandine", color: Color::Grey, armor_die: 6 },
A row is a name, an appearance, and the components the thing carries into the world. There is no dragon code and no ring code: combat reads components and never learns what kind of thing put them there. A few categories need one edit more – somebody has to say what drinking a new potion does – and the docs say which, and why.
To see what you made without playing down to floor 7:
NIHILURK_SPAWN="pink dragon" cargo run -p nihilurk
cargo run -p nihilurk -- -content # every name the game knows
Documentation
docs/ is split by what you need at the moment you open it.
Learning docs/tutorial/ a lesson, followed start to end
Solving docs/how-to/ a recipe for one real task
Information docs/reference/ every field, flag and signature
Understanding docs/explanation/ why it is built this way
cat docs/README.md # the index
cat docs/tutorial/add-your-first-item.md # start here, ~15 minutes
cat docs/tutorial/add-your-first-monster.md # then the bestiary
cat docs/how-to/add-a-monster.md # or go straight to a recipe
Changing the engine rather than the content is a different door:
cat docs/explanation/ecs-in-nihilurk.md # how bevy_ecs is used here
cat docs/how-to/work-with-the-ecs.md # and how to get past the borrow checker
gdd.md is a fifth thing: what nihilurk is trying to be. Design, not code.
License
nihilurk is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. It comes with no warranty. The full text is in LICENSE.
nihilurk: instruction manual
Descend thirteen floors, take the Element of Yoord, and carry it back to the surface. Monsters, traps, gear of unknown quality, and permanent death stand between you and the exit.
If you have played other roguelikes, this table shows where nihilurk parts from them:
| You may expect | Here |
|---|---|
| Resting and searching | You cannot rest, search or pass a turn. Stairs are the only recovery that is not magic. |
| A food clock | No hunger. The Dungeon Lord’s patience runs out floor by floor instead. |
| Experience levels | None. e.g. Nihil gets stronger by finding things and the lurk by killing them. |
| Unidentified items | You know what each one is the moment you find it. Only gear hides its quality. |
| Shops | Spirits give items away or trade them. There is no money. |
| A start-up menu | None, as in Rogue. Your name and your body are arguments. |
| Pets | You can only have one Helper pet that follows you around, other charmed creatures won’t carry through floors |
| Wands | You can zap one, or throw it to spend every charge at once. |
| Gold | A coin is spent the moment you step on it, or you can shoot it. |
Starting the game
If you built the game using cargo, start a new expedition with:
cargo run -p nihilurk # start a game
cargo run -p nihilurk -- YourName # name your nihilurk
cargo run -p nihilurk -- -s 1234 # play a particular seed
cargo run -p nihilurk -- -b lurk # descend as a lurk
cargo run -p nihilurk -- -am dragon # descend as any monster in the dungeon
nihilurk needs a terminal of at least 80 columns by 25 rows. In a smaller one the message log draws garbled letters. That is a known limit and will not be fixed.
The commands above run the game from a clone. After cargo install nihilurk, type nihilurk in their place. If your shell cannot find it, ~/.cargo/bin is not on your PATH; the README’s install steps shows how to add it.
Your name, if you give one, comes first. A name cannot start with -; an argument that does and is not a flag is refused. Everything else comes after it.
Other flags, in any order after the name:
| Flag | Effect |
|---|---|
-c, --centered | Centre the map on you instead of using the fixed viewport. |
-ns, --no-save | Write no save file. |
-nb, --no-blood | No bloodstains, no corpse-and-bones death animation. |
-nshake, --no-shake | No screen shake. |
-mouse, --mouse | Mouse on. Left click looks at a tile, right click travels there. On an aiming reticle left moves, right moves and fires. |
-nobones, --no-bones | A death writes no bones file and an ascent reads none. |
-nrec, --no-recording | Do not record this run. Every new run writes recordings/NAME-SEED.rec in the data directory. |
-v FILE, --replay FILE | Play back a recording. Nothing is saved, scored or dumped, and bones are off. A run resumed from a save has no recording. |
-endless, --endless | No Element of Yoord spawns, so you cannot win. The dungeon keeps going down. |
-anim-rate N, --anim-rate N | Scale animation hold time, 0.1 to 5.0. Raise it if your terminal redraws slowly. |
-keymap FILE, --keymap FILE | Remap keys from FILE. Without it, keymap.txt in the data directory is read if it exists. One pair per line, two characters: hj makes h act as j. List both hj and jh to swap. # starts a comment. Applies to the map and the aiming reticle; menus keep the letters they print. F1 and the sidebar show your keys. |
-content, --content | List every name NIHILURK_SPAWN accepts, and exit. |
-scores, --leaderboard | Print the ten highest scores, and exit. |
-h, -help, --help | Print a short guide, and exit. |
nihilurk speaks English and a bit of Portuguese (pt). Spanish (es) and Haitian Creole (ht) exist in the codebase but need help, so please contribute! The installed nihilurk command picks the language from LC_ALL, LANG or LANGUAGE, and falls back to English if none matches. To choose one yourself, put --lang first:
nihilurk --lang pt
A cargo install holds one language and has no --lang; switch by reinstalling with cargo install nihilurk --force --no-default-features --features lang-pt (or lang-es, lang-ht). The prebuilt download from the releases page holds all four.
From a clone, a language is a build choice and there is no flag:
cargo run -p nihilurk --no-default-features --features lang-pt
Other roguelikes keep a wizard mode for testing. nihilurk has three environment variables instead. NIHILURK_SPAWN drops the things you name around you on every floor, NIHILURK_LEVEL makes every floor one kind of special level, and NIHILURK_MAGICMAP picks the magic mapping animation. The list is in docs/reference/cli-and-env.md.
You may quit and return to one expedition later. Saves are for stopping, not for undoing a death: when an expedition ends, the save is gone. A completed expedition is kept as clear data, so beginning another game after a victory is a choice to enter the dungeon again.
Who goes down
Two creatures were written to go down there, and -b picks between them.
Nihil is the default, and the rest of this manual describes nihil’s expedition: twelve hit points, four magic, and a mace, a bow and a suit of ring mail to start with. Everything nihil is worth in a fight is something nihil is carrying, which means everything can be improved, and everything can be lost.
Nihil plays forward. Every sword, suit and ring you find goes on and makes you better at the one thing nihil does well, which is walk up to trouble and hit it. That makes nihil the body to start with: the choices are few and plain, and a bad one costs you a fight, not the run.
Nihil is born knowing one trick, the charge. Hold Shift and press a
direction with a creature two to six tiles off that way, and nihil crosses the
gap in one turn and strikes. The way there has to be straight and clear: open
floor, no creature in between. The price is that until the creatures have had
their turn, every blow they land on you is 25% harder (VULN on the status
line). You cannot charge with a foe already next to you; you are in the thick
of it, and nihil knows better. A trap on the tile you land on goes off; a trap
you jump over does not.
The lurk (-b lurk) is quadruped, fanged, clawed and furred, and shows on
the map as a magenta @. It is the other way of playing:
- Seven hit points and two magic. It is thinner than nihil in every way that can be measured at the start.
- It carries nothing and can put nothing on but rings. The claws are the weapon and the fur is the armour, so no sword and no breastplate will ever add to either.
- It is quick — half again as fast as anything else in the dungeon,
marked
QUIKon the status line. Three moves for every two the floor gets. - It moves quietly, the way a ring of stealth does: nothing notices it until it is within reach.
- It fights like a fencer without a blade. Step toward a creature with one empty tile between you and the lurk lunges across it: a sure hit at triple the roll, armour ignored. Every blow that lands winds the next one up. Shift still runs, but it cannot charge; that is nihil’s.
- It knows Bide, and pays magic for it like anybody else.
- It eats and grows. Any creature that dies on the floor may feed it:
roughly one in three does, and the lurk gains a point of health, magic,
attack or defence, at random.
FEAR THE LURK!There is no counter to watch and nothing to save up for – it happens while you hunt or it does not.
Where nihil gets stronger by finding things, the lurk gets stronger by killing things. A lurk that avoids fights stays a lurk that can be killed by a bat.
The lurk plays slower than nihil, though it moves faster. With seven hit points it cannot trade blows, so every fight is a question: lunge or wait, bide or step away, drink the potion now or keep it. Nothing it finds can be worn but a ring, so the potions, scrolls, wands and coins carry the run, and each one is spent once. Pick the lurk once nihil’s dungeon feels familiar.
The dungeon screen
Two lines carry everything you need at a glance. Above the map:
DEPTH 3 SCORE 000140
and below it:
BAE · HP 9/12 · Ma 4/4 · Pow. 8+1 · Arm. 5+1
DEPTH tells you where you are. SCORE is your score, except when the dungeon flashes a recent reward or warning in its place.
HP is your health and Ma is your magic, each shown as what you have out of what you can hold. Pow. and Arm. are your attack and your defence: the first number is the die that gets rolled – 8 means a roll of one to eight – and anything after it is added to that roll every time, so 8+1 is two to nine. A cursed item shows as a minus. Pow. turns green when something has poisoned your strength below its usual ceiling.
Short words may follow, one for each thing currently true of you – FAST, QUIK, SLOW, STLH for moving unnoticed, and one apiece for confusion, blindness and the rest.
The message log below the map describes what has just happened. When --MORE-- appears, press Space or Enter to continue reading. Until you do, the rest of the dungeon is waiting for you.
The map uses these symbols:
| Symbol | Meaning |
|---|---|
. | Room floor |
▒ | Corridor |
#, ` | ` |
+ | Door |
> | Stairs down |
< | Stairs up |
@ | You |
| A letter | A monster |
% | A corpse |
^ | A discovered trap |
$ | A coin or other pickup |
! ? / = | Potion, scroll, wand, or ring |
' | A rune: a scroll that goes dark instead of crumbling, and wakes when you take the stairs |
) ] } | Weapon, armour, or launcher |
" | The Element of Yoord |
A monster that cannot fight at full strength is highlighted with a different tint. Dark blue is asleep. Cyan is paralysed: it loses half its turns. Dark green is held fast by a bear trap or a scroll of hold monster. Yellow is confused or fleeing. Grey is slowed.
Grey tiles are places you have seen but cannot currently see. The dungeon remembers walls and corridors, but not the creatures hiding beyond your sight.
Moving and fighting
nihilurk is turn-based. Your action gives the dungeon its turn. You cannot pass a turn, so choose an action whenever you press a key.
Move one square at a time with the arrows, vi keys, or number pad:
| Direction | Arrows | vi keys | Numpad |
|---|---|---|---|
| North | Up | k | 8 |
| South | Down | j | 2 |
| West | Left | h | 4 |
| East | Right | l | 6 |
| Northwest | y | 7 | |
| Northeast | u | 9 | |
| Southwest | b | 1 | |
| Southeast | n | 3 |
Walk into a monster to attack it. There is no separate attack command. You cannot walk diagonally through the corner of two walls, and you cannot attack a wall. Nothing heals you as you walk.
Health is scarce. Armour can turn a blow aside, but no weapon is guaranteed to save you. Now and then you land an excellent hit, and it shatters whatever armour your foe has on. Cursed armour bursts into splinters of evil magic that hurt the monsters around it and spare you and your allies. If a fight is going badly, leave it, use a potion, or find another way around.
Spirits are the shops. They are & creatures, and you make a deal by walking into one. Some give away a choice of items. Others trade if you carry something they want. Deal with them too much, or hurt them, and you upset the balance: every spirit turns hostile. A scroll of atonement makes them peaceful again.
You also carry a hidden alignment, from -3 to +3, between two camps of spirit. The yellow, red, blue and pink demons pull you down. The angel, the sphynx, sylphids, salamanders, undyne and gnomes pull you up. Each time a spirit leaves the floor (a deal done, a kill, a polymorph, anything that removes it) your alignment moves one step toward its camp. Gaining a Helper moves you up one, so a pink demon who joins you pulls you down one and up one. Blowing one up moves you down two, whether a new Helper replaces it or system shock bursts it. Reach +3 or -3 and you upset the balance. Off centre, the word BALANCE shows under the status line, left of the map, as a thermometer: one of its seven letters is tinted for your level, red for the demons’ side and cyan for the angels’, the rest white. When the spirits turn, it reads BROKEN instead, in the colour of the side you leaned to (white if a wound broke it while you were centred). A scroll of atonement and a potion of adjustment both bring it back to 0.
Several commands let you spend less time walking:
| Key | Action |
|---|---|
| Shift + direction | Run until you meet an obstacle or something worth noticing; any body can run. As nihil, with a creature in view that way: CHARGE! Close the gap and strike, but take 25% more from melee until they have moved |
o | Explore the floor automatically |
A | Toggle picking up useful items during auto-explore |
O | Choose a destination and travel there |
Tab | Fight the weakest visible foe automatically (one already beside you comes first); throws a boomerang or moon blade in your hand |
f | Fire the launcher in your hand, or throw the boomerang or moon blade in it |
v | Make a reach attack with a suitable weapon, or throw the boomerang or moon blade in your hand |
F1 | Show the key list |
F2 | Hide or show the yellow key hints at the bottom left |
Auto-explore heads for whatever is nearest: unexplored ground, or an item worth taking. It leaves alone what you dropped, and what you saw in a room and walked out of without.
Automation stops when a monster enters view. Auto-fight will not start when you are badly hurt or confused. Treat these commands as a way to handle safe ground, not as a substitute for looking at the screen.
Using your pack
Items you find are picked up automatically when there is room. Press i to open the pack. Select an item with its letter, the direction keys, and Enter. An item keeps the same letter in every item menu.
These commands go directly to the relevant kind of action:
| Key | Action |
|---|---|
a | Use an item |
t | Throw an item |
d | Drop an item |
e | Equip something you can wear or wield |
q | Quaff a potion |
r | Read a scroll or a rune |
z | Zap a wand |
w | Wield a weapon or launcher |
W | Wear armour |
P | Put on a ring |
Throwing, firing, and most wands open an aiming cursor. Move it with the direction keys, press Enter to act, or press x to cancel. Arrows, quarrels and blowdarts are stronger, and carry twice as far, with the matching bow, crossbow or blowgun in your hand; a blowdart saps the power of whatever it wounds. Anything thrown that was not made for throwing does 1d2, and armour a creature wears (its plus) always takes some of the damage off. Small things — potions, scrolls, wands, rings — fly further out of a bare hand than a spear or a sack of arrows does. A thrown dagger or spear can pass through more than one target; other things may stop at the first creature they hit. A thrown boomerang hits the first creature in its way and flies back to you: to its old place in your pack, and into your hand if you were wielding it. With one in hand, f, v and Tab throw it.
Your pack has limited space. Ammunition shares a slot with matching ammunition, but other items take their own place. Coins are not carried: stepping on one spends it immediately. If a coin cannot help you yet, the dungeon leaves it where it is.
Trick shots. A trap has nobody to bite when nobody is standing on it, so a missile that lands on one sets the whole mechanism off at once, over every square around it. A coin shot the same way bursts just as far and gives its effect to you from across the room. You can only do this to a trap you have already found. A creature standing on a trap you know about, on a coin, or on the Element of Yoord is drawn on a magenta square: hit it and you set off what it is standing on. One burst sets off anything it covers, including traps nobody has found, so a good shot can run a long way. None of this is on your side. Stand too close to your own trick shot and it will catch you as readily as anything else.
Decks of cards
A deck of cards is as rare as a ring. It holds five cards, stacked when the deck turns up, and one or two of them lie reversed. Read it (r) and you play the top card; a reversed card plays its darker side. Some cards play other cards. Throw the deck instead and all that is left plays at once as a poker hand, never reversed, on you. Every card that makes up the hand pays 5,000 points, and a FOOL counts as any card. Five of a kind plays its card five times, then sets everything you wear to +5 and burns off its curses.
Helpers
A treat is food for a creature that is not you. You cannot use one, so throw it. Throw the right treat at a monster and it eats the treat. Half the time it becomes your Helper.
A Helper fights the monsters you can see, comes back to your side when there is nothing to fight, and turns up next to you on every new floor, healed. It has its own background colour. Walk into it to trade places. You get one Helper at a time. A second one blows up the first in a shower of gore, and that costs you alignment (see Spirits above). Wands of polymorph, haste and slow reach your Helper and other allies, not only enemies. Polymorph a Helper that is already polymorphed and it can burst from system shock, which costs you alignment too.
Thrown wands
Zap a wand (z) and it spends one charge. Throw it (t) and it bursts where it lands.
Coins
You do not carry coins. Stepping on one spends it on the spot, and a coin that would do nothing for you stays where it is until it would. Shoot one instead and its effect reaches you from where you stand.
- Some coins are treasure and add to your score.
- Some coins heal you, refill your magic, lift your afflictions, or restore drained strength, up to four points each.
- Two coins make you a promise. Reach a staircase unhurt and it pays: the first adds a point to your attack die or your armour die, the second adds a plus to your weapon or your armour. Any blow that lands on you before then takes the promise back.
- The hero coin teaches you a spell you do not know.
Conducts
A conduct is something you go without. Win a run and each one you kept adds 10% of your score on top, so a clean run is worth much more than a loud one. Die and you earn nothing for them.
- No potions, no scrolls, no wands, no runes.
- No casting, no coins, no throwing or firing, no melee attacks.
- No charming, no boon companion.
- No damage taken, no gear put on, never saved by your armour.
- Played as the lurk.
The win screen shows one coloured mark for each conduct you kept. The run dump lists them by name, and -scores prints the marks after each win.
Unknown things
Potions, scrolls, and wands tell you exactly what they are the moment you find them.
Weapons, armour, and rings hold back one thing: their own quality. An item may be plain, unusually good, or cursed, and you cannot tell which just by picking it up — though a ring always tells you what it does. A ring that is a number (protection, strength, increase damage, sharpshooting) is +2 when plain, +3 when unusually good, and anywhere from -3 to +2 when cursed; the other rings have no number to hide, only the curse. Wearing it settles the question; so does a scroll of identify, read before you commit. A cursed item may be powerful, but once you put it on, it may refuse to come off. Keep a way to remove a curse before testing something you cannot afford to lose. Put on a cursed item you already know about when its slot is full and holds a cursed item, and the two merge (a free finger just takes the ring). Monsters that catch or steal cursed gear merge it too: 45% of the time the worn item survives, 45% the new one does, and 10% both break. Whatever survives takes the new item’s plus and vorpal bane, replacing its own. With two cursed rings on, the first in your pack is the one that merges.
Magic and spells
The Ma pool powers your spells. Stairs refill it; it does not return merely because you wait. Press Z to open the spells menu and pick a spell by its row letter, or press Alt+Q, Alt+W, Alt+E or Alt+R to cast spell one to four directly.
The Alt shortcuts need your terminal to send Alt as a key modifier. Linux and Windows terminals do by default. On macOS, turn on “Use Option as Meta key” in Terminal (Settings, Profiles, Keyboard), or set “Left Option key” to “Esc+” in iTerm2 (Profiles, Keys). Without that, Option types a symbol and the shortcut never reaches the game. Z always works.
A hero coin teaches a new spell when you step on it. You can know only a few spells at once. A spell that needs a target uses the same aiming cursor as a wand.
A staff in your hand changes what your attacking spells are worth: each one costs more magic than usual and hits harder than usual, and it hits harder than it costs. The spells that heal, ward, reveal or steady you are untouched. Nothing on the screen shows this, so the staff says so itself – when you take it up, and again when you put it away.
Polymorph lends you a creature’s powers until the next staircase. A potion, a wand, a ring (which rolls on its own) and two spells, Polymorph Self and Polymorph Other, all do it. You keep your own face and numbers. A shape with no hands cannot hold gear: yours comes off and stays off. POLY on the status line shows it. Polymorph something that is already polymorphed and it is a coin flip: system shock, where a monster bursts and you are left on 1 HP, or a chimera, a typhon or an echidna. The ring never shocks. A ring of sustain form turns every polymorph aside, shock included.
Some effects change how you act. Confusion makes movement unreliable. Blindness limits what you can see. Paralysis and sleep steal time. A medusa’s gaze steals them too, by turning you to stone – but stone is hard: while it lasts nothing gets more than a chip through you and nothing can take your last point of health, unless what is standing over you is swinging a war hammer. Haste makes you quicker; slow makes you slower. A staircase clears these effects.
Exploring the floors
You can always see the squares immediately around you. A lit room reveals its whole interior; corridors reveal much less. Monsters disappear from the map when they leave your sight, even though the floor remains in your memory.
Traps may announce themselves, wait until you approach, or remain hidden until they spring. A discovered trap is marked with ^. Do not assume that an empty-looking corridor is safe merely because the last one was.
The Dungeon Lord allows only so much hesitation on each floor. If you stay too long, a portal carries you to another depth whether you are ready or not. The automatic movement commands exist partly to help you cross ground that has already proved safe.
Reaching a new floor restores some health and all of your magic. A trapdoor is not a rest; it is a fall.
The seed fixes the shape of the floors, but not everything you will meet there. A familiar corridor may hold unfamiliar monsters, traps, and treasure when you return.
The descent and the return
Take the down stairs to reach deeper floors. On the thirteenth floor there is no staircase down. The Element of Yoord waits there instead.
Taking a staircase tells you how the new floor looks against the usual for its depth. A magenta line says it is dangerous, a dark magenta one says it is safe. A yellow line says it is rich, a dark yellow one says it is poor. An ordinary floor says nothing. The count covers every monster and every item, hidden ones included. Trapdoors and portals give no feeling.
When you take the Element, you must climb instead of descend. The creatures you meet may be drawn from anywhere in the dungeon.
To win, carry the Element up and walk out of the dungeon from the first floor. A portal can move you between floors, but it cannot win the expedition for you.
Death is permanent. There is no retry or resurrection.
Useful commands
| Key | Action |
|---|---|
> or . | Go down or walk to the down stairs |
< or , | Go up or walk to the up stairs |
; | Look around: move the cursor over any tile in view to have it described, including what a monster is dangerous for |
Z | Open the spells menu |
Alt+Q W E R | Cast spell one to four directly |
x | Swap between a melee weapon and a launcher (one turn). With a menu or cursor open, close it instead |
X | Close the current menu |
Esc | Back out of a menu; on the bare map, quit after confirmation |
Ctrl+C | Quit immediately. No other Ctrl key does anything |
x takes the next weapon in your pack, wrapping round, but only across the divide: a melee weapon gives way to a launcher and a launcher to a melee weapon. It skips weapons whose quality you have not learned yet and weapons you know are cursed, and a cursed weapon in hand blocks it. Quitting saves the current expedition unless saving has been disabled. While time is stopped nothing is saved: the game saves as time stops and again as it starts. Quitting in between warns you in red and asks a second time, and those turns are lost.
The map can be centred, screen shake can be disabled, and blood can be hidden with command-line options. For the complete list, see docs/reference/cli-and-env.md.
Quick reference
Move arrows / hjkl / numpad; diagonals yubn or 7913
Attack walk into a monster
Run Shift + direction
Explore o A toggles pickups
Travel O, steer, Enter
Auto-fight Tab
Fire f with a bow, crossbow or blowgun drawn
Reach v with a reach weapon
Look ; steer the cursor, no turn spent
Stairs > down, < up (or . and ,)
Pack i
Use a throw t drop d
Quaff q read r zap z
Equip e wield w wear W ring P
Spells Z pick a slot by its row letter
Alt + Q/W/E/R for slots one to four
Cancel x or X never spends a turn
Quit Q or X with nothing open; Ctrl+C skips confirmation
Tutorial: add your first body
Audience Anyone who wants a new way to play the whole game,
not a new thing to meet in it. You have done
`add-your-first-monster.md`, or you are comfortable
enough to skip it.
Prerequisites A checkout, and a working `cargo`.
Time About twenty minutes.
You will Add a ninja: quick, quiet, poisoned steel, and a
fistful of daggers — built almost entirely out of
abilities and gear that already exist.
This is a lesson, not a recipe. When you want the short version, read ../how-to/add-a-body.md.
%%{init: {'theme':'base','themeVariables':{
'primaryColor':'#20242b','primaryTextColor':'#d7dae0',
'primaryBorderColor':'#5c6370','lineColor':'#8a8f98',
'fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace',
'fontSize':'13px'}}}%%
flowchart LR
S1["1 · find<br/>enum Body"]:::cold --> S2["2 · add<br/>the variant"]:::hero
S2 --> S3["3 · borrow<br/>Quick + Stealthy"]:::magic
S3 --> S4["4 · borrow<br/>Venomous"]:::magic
S4 --> S5["5 · Sting in<br/>the spell bar"]:::magic
S5 --> S6["6 · gear:<br/>equip_silently"]:::hero
S6 --> S7["7 · -b ninja<br/>meet it"]:::peril
S7 --> S8["8 · a staircase,<br/>not a reload"]:::cold
classDef hero fill:#3a3418,stroke:#d7ba4a,color:#e8dfa8
classDef peril fill:#3a1f1f,stroke:#c05050,color:#f0c8c8
classDef magic fill:#2f2038,stroke:#a86fc0,color:#e6cdf0
classDef cold fill:#17323a,stroke:#4aa3c0,color:#bfe4f0
What you are about to learn
There is no Class trait and no impl Ninja. body.rs is one enum,
Body, with a hand-written function per variant that dresses the player —
wear_lurk is the one that already exists. A new body is a sibling of
that function, not a new shape.
The point of this lesson is that a body barely has to invent anything. Every piece of the ninja — its speed, its quiet, its poison, even the fact that it starts holding a weapon — is something the game already knows how to do for somebody else. Writing the ninja is mostly pointing at those things.
By the end you will have a body that:
- moves at the lurk’s tempo and walks as quietly as the lurk does,
- bites like a rattlesnake,
- carries the spell Sting, the way the lurk carries Bide,
- and spawns already wearing armour and a ring, and wielding a dagger —
which is also your look at
equipment::equip_silently, the function that hands somebody gear without a word being logged about it.
Step 1: find the enum
Everything a body is lives in one file:
models/src/body.rs
Open it and find the enum:
pub enum Body {
#[default]
Nihil,
Lurk,
Monster(&'static MonsterDef),
}
Three answers today, and the module’s own doc comment at the top of the file explains why it’s one enum and not a flag per body: a player is exactly one of these, and the argument parser has nothing to cross-check. Read that comment before you touch anything below it.
Step 2: add the variant
pub enum Body {
#[default]
Nihil,
Lurk,
Ninja,
Monster(&'static MonsterDef),
}
Body::name() needs an arm too — it’s what -b matches against, and what
an error about the body prints:
pub fn name(self) -> &'static str {
match self {
Body::Nihil => "nihil",
Body::Lurk => "lurk",
Body::Ninja => "ninja",
Body::Monster(def) => def.name,
}
}
And wear needs to route to a function you haven’t written yet:
pub fn wear(world: &mut World, player: Entity, body: Body) {
match body {
Body::Nihil => {}
Body::Lurk => wear_lurk(world, player),
Body::Ninja => wear_ninja(world, player),
Body::Monster(def) => crate::monsters::wear_monster(world, player, def),
}
}
Build:
cargo build
It won’t compile — there is no wear_ninja yet. That’s the whole rest of
the lesson.
Step 3: borrow the lurk’s tempo and its quiet
Scroll down to wear_lurk. It inserts a bundle of components and then
grants a slice of effects through grant_all:
const LURK_GRANTS: &[Grant] = &[
Grant::of::<Lurk>(),
Grant::of::<Lunges>(),
Grant::counted::<Fencer>(lurk::NUMBER_OF_ATTACKS),
Grant::of::<BuildsMomentum>(),
Grant::of::<Stealthy>(),
];
Stealthy is a plain marker component — the same one a ring of stealth
grants, the same one rings::STEALTH_RANGE reads back out regardless of who
is wearing it. Nothing about it belongs to the lurk; the lurk just happens
to be the first thing that asked for it unconditionally instead of from a
ring. A ninja can ask for the same marker:
const NINJA_GRANTS: &[Grant] = &[Grant::of::<Stealthy>()];
And the tempo is the same story. wear_lurk inserts:
Speed::new(SpeedKind::Quick),
SpeedKind::Quick already exists — it’s the lurk’s tempo, half again as
fast as Normal. Start wear_ninja the same way wear_lurk starts,
borrowing both:
fn wear_ninja(world: &mut World, player: Entity) {
world.entity_mut(player).insert((
Renderable {
glyph: '@',
color: Color::DarkGrey,
},
Fighter {
hp: 8,
max_hp: 8,
power: 2,
max_power: 2,
power_bonus: 0,
armor: 2,
armor_bonus: 0,
},
Magic {
points: 2,
max_points: 2,
},
Speed::new(SpeedKind::Quick),
));
grant_all(world, player, NINJA_GRANTS);
}
The numbers are placeholders for now — 8 HP, nihil’s own bare 2/2 dice, 2
Magic. You’ll pull them out to constants.rs in the step where you decide
you’re keeping this.
Step 4: borrow the rattlesnake’s poison
Venomous is not a body’s effect at all — it’s the rattlesnake’s, over in
monsters.rs:
MonsterDef::row("rattlesnake", 'R', Color::DarkGreen, Chase, 6, 6, 0, 8, 0, 5)
.grants(&[Grant::of::<Venomous>()]),
Nothing stops the same marker riding on a player instead of a bestiary row —
Grant::of::<Venomous>() doesn’t know or care what kind of entity is
carrying it, the same way Stealthy didn’t. Add it to the ninja’s own
list:
const NINJA_GRANTS: &[Grant] = &[Grant::of::<Stealthy>(), Grant::of::<Venomous>()];
Add Venomous to the use crate::effects::{...} line at the top of body.rs.
Rebuild:
cargo build
Still short one thing: wear_ninja is called from nowhere until you also
handle the spell and the gear.
Step 5: put Sting in the spell bar
wear_lurk ends by handing the lurk one spell it has to pay for:
if let Some(mut spellset) = world.get_mut::<Spellset>(player) {
spellset.slots.push(SpellEffect::Bide);
}
Sting already exists — a venomed dart at range, 1 Magic, in
items/spells.rs. Give the ninja the same push, at the end of
wear_ninja:
if let Some(mut spellset) = world.get_mut::<Spellset>(player) {
spellset.slots.push(SpellEffect::Sting);
}
It costs Magic like anybody else’s copy of it would — nothing is
special-cased in spell_cost, and a dragon-bodied player pays for its
Fireball the same way (see ../reference/cli-and-env.md, “-am <species>”).
Step 6: gear, and equip_silently
Everything so far was an effect with nothing to spawn. Gear is different —
a dagger, a suit of armour and a ring are entities, and wear_ninja has to
build them the same way the floor stocks anyone else’s kit: spawn at the
origin, strip the Position so it’s carried and not lying on a tile
(levels.rs::starting_kit does the identical thing for nihil’s mace and
ring mail), put it in the Backpack, then equip it.
equipment::equip_silently(world, wearer, item) is the function for that
last part — it puts the item on without narrating it, which is exactly
what you want for something the player is born wearing rather than
something they just picked up and pulled on. Import it beside Slot in the use crate::equipment:: line.
let origin = Position { x: 0, y: 0 };
let strip = |world: &mut World, item: Entity| {
world.entity_mut(item).remove::<Position>();
};
let armor = crate::catalog::spawn_armor(world, "leather armor", origin);
strip(world, armor);
let ring = crate::catalog::spawn_ring(world, RingEffect::Protection, origin);
strip(world, ring);
let daggers: Vec<Entity> = (0..5)
.map(|_| {
let dagger = crate::catalog::spawn_weapon(world, "dagger", origin);
strip(world, dagger);
dagger
})
.collect();
if let Some(mut pack) = world.get_mut::<Backpack>(player) {
pack.items.push(armor);
pack.items.push(ring);
pack.items.extend(daggers.iter().copied());
}
equip_silently(world, player, armor);
equip_silently(world, player, ring);
equip_silently(world, player, daggers[0]);
Five daggers: one wielded, four spare in the pack — a dagger is also
.missile(4), built to be thrown, so the spares are a ninja’s ammunition as
much as a backup blade. equip_silently refuses quietly if a slot is
already full or the item has nowhere to go (Equipped names its own slot),
so the order above — armour, then ring, then the first dagger — is safe to
follow literally.
Build:
cargo build
It compiles. -b doesn’t know the word “ninja” yet — that’s the last wire.
Add a line in engine/src/main.rs, next to the two that are already there:
match (flag, name.as_str(), models::MonsterDef::lookup(name)) {
("-b", "nihil", _) => body = Some((models::Body::Nihil, "-b")),
("-b", "lurk", _) => body = Some((models::Body::Lurk, "-b")),
("-b", "ninja", _) => body = Some((models::Body::Ninja, "-b")),
("-b", _, _) => unknown_body = Some((name.clone(), "-b")),
...
}
Step 7: meet it
cargo build
cargo run -p nihilurk -- -b ninja
You wake up quick, quiet, venomous, holding a dagger, wearing leather and a
ring of protection, with four spare daggers and Sting in your spell bar.
Open the pack (i) and look — the armour and ring show as worn, the same
as nihil’s ever do.
Fight something and watch the log: “Venom courses through the <name> -
its strength ebbs away,” the same sentence a rattlesnake’s bite would
read against you, just turned around to name what you bit instead.
Step 8: the part a body cannot skip
It is tempting to guess this bug lives behind a save and reload, the way a lurk’s identity does. It doesn’t — try it first, so you believe the actual answer:
cargo run -p nihilurk -- -b ninja -ns
# play a turn or two, quit, then:
cargo run -p nihilurk -- -b ninja -ns
You come back quick, quiet and venomous, exactly as you left. Speed.kind,
Stealthy and Venomous are all ordinary saved state — a plain field and
two rows in the EFFECTS ledger — and none of them need the body to be
identified to round-trip correctly. A reload is not where this breaks.
A staircase is. Find one and take it (>):
You come back Normal-paced. Not hasted, not slowed — the HUD’s tempo badge
is simply gone, because Normal prints nothing. Nothing else about you
changed: still quiet, still venomous, still holding the same daggers.
Here’s why, and it’s narrower than it first looks. crate::conditions::clear_player_conditions
runs on every staircase (and on a wand of cancellation), and it always
sets Speed.kind back to crate::body::innate_tempo(world, player) — “back
to the body’s own tempo… it has no business lifting what the player is
made of,” in its own comment. innate_tempo answers that question by
checking for a Lurk marker, then a MonsterBody marker, and defaulting to
Normal if it finds neither. The ninja has neither. So the very first
staircase — not a reload, not anything special — asks “what is this
creature’s own tempo?”, gets the only honest answer available (“nothing
marks it as anything”), and quietly demotes a body that was supposed to be
permanently quick.
Stealthy and Venomous don’t have this problem only because nothing else
in the game ever takes them away and asks what to give back — a staircase
lifts a ring of stealth’s temporary loan the same way it lifts a potion’s,
but nothing analogous exists for these two, so their absence of an identity
marker never gets tested. Tempo is the one property in the whole body
system that is actively restored rather than merely held, which is
exactly why it’s the one that exposes a body with no identity of its own.
The fix is the one line the lurk’s Lurk component exists for: give the
ninja a marker of its own that nothing else in the game would ever attach
to a player by accident (Stealthy and Venomous both fail that test —
a ring of stealth and a rattlesnake’s bite can each hand a plain nihil the
same marker), and teach innate_tempo to read it the way it already reads
Lurk and MonsterBody. Picking that marker, and confirming it survives a
save the way Lurk does, is left as the next step — exactly the shape
../how-to/add-a-body.md describes it in.
Step 9: keep it or drop it
If you like the ninja, decide on its save marker and finish the job. If this was a dry run:
git checkout models/src/body.rs engine/src/main.rs
What you actually learned
-
A body is a hand-written function, not a table row —
../how-to/add-a-monster.md’s “one line in one table” does not apply here. What does carry over is reuse: almost nothing a body needs has to be invented. -
An effect marker doesn’t belong to whoever you first saw it on.
Stealthyis the lurk’s in the same senseVenomousis the rattlesnake’s — which is to say, not really. Both are just components aGrantcan hand to any entity at all. -
Gear a creature is born with is built exactly like gear the floor hands out: spawn it, strip its
Position, and either put it in aBackpackor (for something worn from the first turn) also run it throughequip_silently. -
A body needs an identity marker for reasons sharper than “does it survive a save” — ordinary saved state survives a save regardless. It’s the code that actively restores something (
innate_tempo, read on every staircase) that needs to ask “what is this creature, really” — and a body that never answers that question gets the default answer, silently, the first time anything asks. Borrowing an effect for its behaviour and borrowing one for its identity are two different questions, and the ninja above answered only the first.
Where to go next
../how-to/add-a-body.md the short version, as a recipe — including the save marker this tutorial left undone
add-your-first-monster.md the same kind of lesson, for a bestiary row
../how-to/add-an-effect.md where Stealthy and Venomous actually come from
../reference/cli-and-env.md -b and -am, from the player’s side
../reference/components.md Body, MonsterBody, StartingBody
Tutorial: add your first item
Audience Anyone who wants to add content to nihilurk. You have
written a little Rust, or a little of something like
it. You have never touched this codebase.
Prerequisites A checkout, and a working `cargo`. That is all.
Time About fifteen minutes.
You will Add a weapon to the game, see it in the dungeon,
prove it works, then change how it behaves.
This is a lesson, not a recipe. It takes the longest road on purpose. When you want the short version, read ../how-to/add-an-item.md.
%%{init: {'theme':'base','themeVariables':{
'primaryColor':'#20242b','primaryTextColor':'#d7dae0',
'primaryBorderColor':'#5c6370','lineColor':'#8a8f98',
'fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace',
'fontSize':'13px'}}}%%
flowchart LR
S1["1 · find<br/>the table"]:::cold --> S2["2 · add<br/>a row"]:::hero
S2 --> S3["3 · -content<br/><i>the game knows it</i>"]:::cold
S3 --> S4["4 · NIHILURK_SPAWN<br/><i>hold it</i>"]:::hero
S4 --> S5["5 · cargo test<br/><i>it stays proved</i>"]:::cold
S5 --> S6["6 · .missile(6)<br/><i>change what it does</i>"]:::magic
S6 --> S7["7 · keep it,<br/>or git checkout"]
classDef hero fill:#3a3418,stroke:#d7ba4a,color:#e8dfa8
classDef peril fill:#3a1f1f,stroke:#c05050,color:#f0c8c8
classDef magic fill:#2f2038,stroke:#a86fc0,color:#e6cdf0
classDef cold fill:#17323a,stroke:#4aa3c0,color:#bfe4f0
What you are about to learn
In nihilurk, a piece of content is a row in a table. Not a class, not a file, not a subclass of Weapon – a line. The line says what a thing is called, how it draws, and what it carries into the world. Nothing else in the game enumerates weapons, so nothing else needs to hear about yours.
By the end you will have added a quarterstaff, and you will believe that sentence.
Step 1: find the table
Every item in the game lives in one file:
models/src/catalog.rs
Open it and search for WEAPONS. The top of it looks like this:
#[rustfmt::skip]
pub const WEAPONS: &[WeaponDef] = &[
WeaponDef::new("dagger", Color::Grey, 4).missile(4).piercing(),
WeaponDef::new("spear", Color::DarkGrey, 6).missile(8).piercing(),
WeaponDef::new("mace", Color::DarkGrey, 6),
WeaponDef::new("long sword", Color::White, 8),
WeaponDef::new("two-handed sword", Color::Cyan, 10),
// ... more rows ...
];
One weapon, one line. Read one:
WeaponDef::new("mace", Color::DarkGrey, 6)
| | |
its name its colour its damage die
The mace rolls 1d6 when you swing it. The long sword rolls 1d8. That third number is the whole of what a weapon is worth in a fight – see ../reference/content-tables.md if you want the combat maths now, though you do not need it yet.
Step 2: add a row
A quarterstaff should sit between the mace and the long sword: a bit better than a club, nowhere near a sword. Add this line after the mace:
WeaponDef::new("quarterstaff", Color::DarkYellow, 7),
Line the columns up with its neighbours. The table carries #[rustfmt::skip], which means cargo fmt will leave your alignment alone – these tables are meant to be read as columns.
Now build:
cargo build
It compiles. That is the entire change. There is no registration step, no match arm waiting for you, no list of weapon names somewhere else that is now out of date.
Step 3: see that the game knows about it
Ask the game what content it has:
cargo run -p nihilurk -- -content | grep quarterstaff
You should get:
quarterstaff
That listing is not a hand-maintained document. It reads the tables at run time, walks every row, and prints what it finds. Your weapon is in it because the table is the only place weapons are defined.
Step 4: hold it in your hands
Waiting for a rare drop to prove your work is a miserable way to spend an evening. So there is a shortcut:
NIHILURK_SPAWN="quarterstaff" cargo run -p nihilurk
The floor is built as normal, and then the things you named are dropped on free tiles next to you. Walk one step, pick it up, wield it.
You can ask for several at once, and mix kinds freely:
NIHILURK_SPAWN="quarterstaff,dragon,ring of protection" cargo run -p nihilurk
Anything the tables do not recognise is skipped in silence. See ../reference/cli-and-env.md.
Step 5: prove it, so it stays proved
Looking at it is not the same as knowing it works. Run the table tests:
cargo test --test it content::
Several of those tests just read your new row. They checked that no two rows share a name, that every name spawns something, that what spawns keeps the name it was spawned by, and that the loot table can still produce one of everything. Your quarterstaff was included automatically, because those tests iterate the tables too.
That is the pattern worth taking away: in this codebase, the tables are data, so tests can check the data. You did not have to write a test to get your row tested.
Step 6: change what it does
A row is not only numbers. It is also behaviour, expressed by chaining. Look at the dagger again:
WeaponDef::new("dagger", Color::Grey, 4).missile(4).piercing(),
.missile(4) says: this thing is built to be thrown, and rolls 1d4 when it lands. .piercing() says: a throw does not stop at the first body – it runs the whole line you aimed down.
Suppose your quarterstaff is really a javelin. Change your row to:
WeaponDef::new("quarterstaff", Color::DarkYellow, 7).missile(6),
Rebuild, and throw it at something:
NIHILURK_SPAWN="quarterstaff,emu" cargo run -p nihilurk
It now flies properly: it is spent on what it hits, and nothing can pluck it out of the air. You did not implement any of that. Those behaviours belong to .missile(...), and every row that asks for them gets both.
Nothing in the throwing code knows a quarterstaff exists. It asks “is this a Projectile?”, and your row answered.
Step 7: keep it or drop it
If you like the quarterstaff, leave it. If this was a dry run:
git checkout models/src/catalog.rs
What you actually learned
-
Content is a row in a table, and the table is the only definition.
-
Behaviour is attached by naming it, not by writing it.
.missile()and.piercing()are shorthands for components, and the systems that care look for the components, never for your item. -
Nothing else has to be told. No registry, no factory, no
match. -
The tables are data, so the tools work on them for free: the
-contentlisting,NIHILURK_SPAWN, and the table tests all read the same rows you edited.
Weapons are the easy case. Some categories need one more edit – a potion needs somebody to say what drinking it does. The next section is one worked row for each kind, so you can see exactly where the line falls.
One row for every kind
Every category that drops in nihilurk falls into one of two halves: the ones that are a row and nothing else, and the ones that also need somebody to say what the new thing does.
The second half is not a chore the design failed to remove. A potion is a promise that drinking it will do something, and no table can invent what. What the design does remove is everything else: you never register a type, never touch the loot roller, never add a name to a list.
Try any of these the way you tried the quarterstaff – add the row, cargo build, then NIHILURK_SPAWN="<name>" cargo run -p nihilurk.
A row and nothing else
Armour. ARMORS in catalog.rs. One number: what wearing it adds to your defence die.
ArmorDef { name: "brigandine", color: Color::Grey, armor_die: 6, grants: &[] },
A weapon. WEAPONS, as above.
A coin, as long as it does something a coin already does. COINS pairs a PickupEffect with the one number that effect works with – so a cheaper treasure coin is a row, full stop:
CoinDef { name: "copper coin", color: Color::DarkYellow,
effect: PickupEffect::Coin, amount: 250, weight: 10 },
A ring, as long as what it does is a number combat already folds or a marker some system already asks about. This one is the whole argument for the design in three lines:
RingDef::new(RingEffect::FireResistance, "ring of fire resistance")
.grants(&[Grant::of::<FireImmune>()]),
FireImmune is what a dragon is born with. The wand-of-fire code asks whether its target carries it and has never heard of rings, so wearing one works the moment the row exists. RingEffect::FireResistance is a variant you append to the enum. It is just the key the row is spawned by – an identity, not behaviour.
A treat. TREATS. A treat is thrown at a creature as an offer of loyalty; for_item_users says whether it is meant for a creature with hands.
TreatDef { name: "bone", color: Color::Grey, for_item_users: false },
Ammunition and a launcher are one job in two rows, and neither names the other. They meet at an effect:
// in effects.rs, alongside FireArrow and FireQuarrel
#[derive(Component, Default, Clone, Copy)]
pub struct FireStone;
// in AMMO
AmmoDef { name: "sling stone", color: Color::Grey, die: 3, venom: false, launched_die: 6,
launched_by: Grant::of::<FireStone>() },
// in LAUNCHERS
LauncherDef { name: "sling", color: Color::DarkYellow,
grants: &[Grant::of::<FireStone>()], melee_cap: 1 },
Thrown by hand the stone rolls its die. Loosed by somebody carrying FireStone it rolls its larger launched_die. The sling is not consulted – only the effect is, which is why a hobgoblin that picks one up shoots just as well as you do. Add FireStone to the EFFECTS registry so it survives a save; see ../how-to/add-an-effect.md.
A row, plus one place that says what it does
Each of these is keyed by an enum, and the mechanic that reads it is an exhaustive match with no catch-all. That is deliberate: a new variant does not compile until somebody has said what it does. You cannot ship a potion that silently does nothing.
A potion. Row in POTIONS, variant on PotionEffect, arm in apply_potion_effect (items/potions.rs):
PotionDef { effect: PotionEffect::Levitation,
name: "potion of levitation", color: Color::Cyan },
The arm returns bool – whether the dose visibly took hold.
A scroll. Row in SCROLLS, variant on ScrollEffect, arm in apply_scroll_effect (items/scrolls.rs). A scroll has no colour: it is always white, and what varies is its unreadable title.
ScrollDef { effect: ScrollEffect::ProtectArmor,
name: "scroll of protect armor" },
A wand. Row in WANDS, variant on WandEffect, arm in apply_wand_effect (items/wands.rs) – plus one more, because a wand can be thrown as well as zapped: give it a colour in blast_palette, and add it to is_attack_wand if it should deal damage rather than deliver an effect.
WandDef { effect: WandEffect::Sleep, name: "wand of sleep",
color: Color::Blue, range: 6 },
Most of a wand’s arm is borrowed. Sleep is conditions::snare(world, victim, Grant::of::<Asleep>(), turns) and nothing else; the trap and the scroll that also put things to sleep call the same verb.
A genuinely new kind of coin – one that does something no coin does yet – needs a PickupEffect variant and arms in both apply and would_help (items/pickups.rs). would_help is the interesting one: it is what leaves a coin on the floor when taking it would do nothing, so a red coin waits at full health for the fight that goes badly.
The one thing to check afterwards
Potions, scrolls, wands and rings are always shown by their true name – there is no cosmetic appearance to keep in step with the table you just edited, and nothing to check. The one thing a new row must not be is a dud: cargo test --test it content:: fails if a catalog row ever spawns PotionEffect::Water, ScrollEffect::BlankPaper or WandEffect::Nothing, which exist only as what a wand of cancellation turns a carried item into.
cargo test --test it content::
Where to go next
add-your-first-monster.md the same lesson, for the bestiary
../how-to/add-an-item.md every item category, one recipe each
../how-to/add-an-effect.md adding a property like FireImmune
../reference/content-tables.md every field of every table
../explanation/data-driven-content.md why it is built like this
Tutorial: add your first monster
Audience Anyone who wants to put a creature in the dungeon.
You have written a little Rust, or a little of
something like it. You have never touched this
codebase.
Prerequisites A checkout, and a working `cargo`. That is all.
`add-your-first-item.md` first if you have it in you;
this page assumes nothing from it, but they rhyme.
Time About fifteen minutes.
You will Add a creature to the bestiary, meet it, prove it
works, then give it magic nobody wrote code for.
This is a lesson, not a recipe. It takes the longest road on purpose. When you want the short version, read ../how-to/add-a-monster.md.
%%{init: {'theme':'base','themeVariables':{
'primaryColor':'#20242b','primaryTextColor':'#d7dae0',
'primaryBorderColor':'#5c6370','lineColor':'#8a8f98',
'fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace',
'fontSize':'13px'}}}%%
flowchart LR
S1["1 · find the<br/>bestiary"]:::cold --> S2["2 · add<br/>a row"]:::hero
S2 --> S3["3 · -content<br/><i>and five other<br/>systems, free</i>"]:::cold
S3 --> S4["4 · NIHILURK_SPAWN<br/><i>meet it</i>"]:::peril
S4 --> S5["5 · content +<br/>determinism"]:::cold
S5 --> S6["6 · .grants()<br/>.casts()<br/>.weight()"]:::magic
S6 --> S7["7 · keep it,<br/>or git checkout"]
classDef hero fill:#3a3418,stroke:#d7ba4a,color:#e8dfa8
classDef peril fill:#3a1f1f,stroke:#c05050,color:#f0c8c8
classDef magic fill:#2f2038,stroke:#a86fc0,color:#e6cdf0
classDef cold fill:#17323a,stroke:#4aa3c0,color:#bfe4f0
What you are about to learn
There is no emu class in nihilurk. There is no Monster trait, no impl Dragon, and no file called monsters/ full of behaviour. There is one table, and an emu is a line in it.
By the end you will have added a basilisk that is immune to fire, invisible, rare, and waiting on the deep floors – and you will not have written a single line of behaviour to get any of that.
Step 1: find the bestiary
Every creature in the game lives in one file:
models/src/monsters.rs
Open it and search for BESTIARY. One line per species. Read the emu:
// name glyph colour move hp pow pb ar ab dep
MonsterDef::row("emu", 'E', Color::DarkGreen, Chase, 6, 4, 0, 4, 1, 1),
Ten columns, and the header comment above the table names them. The four that decide how a fight goes are the middle ones:
hp how much it can take
pow its attack die: it rolls 1d[pow] against you
pb a flat number added to that roll, once
ar its defence die: it rolls 1d[ar] against your swing
ab a flat number added to *that* roll, once
There is no to-hit roll and nothing ever misses. A blow is your roll minus its roll, and what is left comes off somebody’s hit points. That is the whole of combat; see ../explanation/combat-and-balance.md when you want to know why the numbers are small.
The last column, dep, is the shallowest floor the creature appears on. The emu starts at 1. The dragon starts at 10.
Step 2: add a row
A basilisk should sit in the middle stat band: tough, slow to kill, and hitting hard. Add this line after the emu, keeping the columns lined up:
MonsterDef::row("basilisk", 'b', Color::DarkGreen, Chase, 5, 8, 1, 8, 1, 5),
Chase is the only tactic that walks toward you. The others are Flee (walks away), Static (never acts at all – the inert placeholder the test suite reaches for), Ambush (lies in wait and never approaches, but lunges the instant you draw alongside it – the venus flytrap, the ice monster) and Confused (staggers at random, which is what anything does after a flash of light).
The table carries #[rustfmt::skip], so cargo fmt will leave your alignment alone. These tables are meant to be read as columns.
Now build:
cargo build
It compiles. That is the entire change. There is no registration step, no spawn function to teach, no list of species anywhere else that is now out of date.
Step 3: see that the game knows about it
Ask the game what it has:
cargo run -p nihilurk -- -content | grep basilisk
You should get:
basilisk
That listing is not a hand-maintained document. It walks the tables at run time. Your basilisk is in it because the table is the only place creatures are defined.
More than the listing changed, and this is the part worth pausing on. Your basilisk is now:
- something a floor at depth 5 or deeper can populate itself with,
- something a wand of polymorph can turn an emu into,
- something a scroll of create monster can conjure,
- a legal bane for a scroll of vorpalize weapon,
- and something that comes back correctly out of a save file.
You did not do any of that. Every one of those five reads the same table, so none of them had to be told.
Step 4: meet it
Playing down to floor 5 to look at your own work is a miserable way to spend an evening. So:
NIHILURK_SPAWN="basilisk" cargo run -p nihilurk
The floor is built as normal, and then the things you named are dropped on free tiles near you. NIHILURK_SPAWN ignores the depth gate, so you can look at a floor-10 creature on floor 1.
Fight it. Then try it with company:
NIHILURK_SPAWN="basilisk,basilisk,potion of healing" cargo run -p nihilurk
Step 5: prove it, so it stays proved
Looking at it is not the same as knowing it works:
cargo test --test it content::
Several of those tests just read your new row. They checked that no two species share a name, that every name spawns something, that what spawns keeps the name it was spawned by, and – the one that matters most for a monster – that nothing ever turns up above its min_depth.
Then run the one that protects everybody else’s saved seeds:
cargo test --test it determinism::
That test says adding content cannot move a wall. A floor’s layout is a pure function of (seed, depth) and is drawn from its own RNG stream, so your basilisk cannot reach into somebody’s seed 1234 and rearrange it. If you ever make that test go red, you have drawn from the wrong stream – and it is much better to find out here.
Step 6: give it magic nobody wrote
A row can carry more than numbers. Chain onto it:
MonsterDef::row("basilisk", 'b', Color::DarkGreen, Chase, 5, 8, 1, 8, 1, 5)
.grants(&[Grant::of::<FireImmune>()])
.casts(2, &[SpellEffect::Thunderbolt])
.invisible()
.weight(4),
Four things happened, and you wrote no code for any of them.
.grants(...) hands the creature a property. FireImmune is a plain marker component. The wand-of-fire code asks whether its target carries one, and that is the whole check. It has never heard of a basilisk, or of a dragon, or of any ring that might grant the same thing to a player. Every effect on the list works this way; see ../reference/content-tables.md for what is available, and ../how-to/add-an-effect.md for adding one.
.casts(2, &[...]) teaches it a spell and the Ma for two casts of it. Thunderbolt costs 1, so the basilisk is born with 2 Ma and a spellset of one. The chaser rule set tries a spell before it tries a bite, so the basilisk zaps you from range until the Ma runs out, and then it walks up and bites. The pool never refills.
.invisible() makes it unseeable without second sight. The visibility system already knows what to do with that, because the phantom needed it first.
.weight(4) makes it rarer. DEFAULT_SPAWN_WEIGHT is the baseline every row sits at unless it says otherwise, so a weight below it is rarer than its floor-mates. The weights are relative to each other and to nothing else: you can make one creature rarer without touching any other number.
Rebuild and meet it now:
NIHILURK_SPAWN="basilisk" cargo run -p nihilurk
It is somewhere next to you and you cannot see it. Try burning it with NIHILURK_SPAWN="basilisk,wand of fire" – it will shrug, and the log will say so in as many words.
A creature made of grants
The best row to read next is the dog. Find it in models/src/monsters.rs:
MonsterDef::row("dog", 'd', Color::DarkYellow, Chase, 8, 6, 0, 7, 0, 3)
.grants(DOG_GRANTS)
.weight(2),
The numbers are a plain chaser’s. It uses Chase, so it thinks with the same rule set as the dragon and tries a spell first. Everything that makes it a dog is in DOG_GRANTS, a list of grants with one behaviour each:
AlwaysTamed: any treat tames it, every time.AlwaysHelper: a charm or a scroll of create monster makes it your Helper, not a plain ally.PriorityHelper: it never explodes to make room, so you can keep any number of dogs. Recruiting one explodes the ordinary Helper.ShapeshiftOnKill: each melee kill may turn it into another random monster (constants::helpers::SHAPESHIFT_CHANCE).FaerieOnDeath: when it dies it is revealed as a faerie shapeshifter and is gone, with no gore.
No system in the game mentions dogs. Each one asks about its own grant, so any creature that carries the grant gets the behaviour. Try it:
NIHILURK_SPAWN="dog,snack" cargo run -p nihilurk
Pick up the snack and throw it at the dog (t). A snack tames a creature without hands on a constants::helpers::ACCEPT_CHANCE roll. It tames a dog every time. Walk a dog into a fight and watch for “It was never a dog, but a …!”. The (d) is a dogppelganger: a faerie shapeshifter wearing a dog, which is why it can change its shape and why no dog ever dies.
Now pick up a wand of cancellation (NIHILURK_SPAWN="dog,wand of cancellation") and zap the dog. Nothing changes. The dog’s grants are identity effects: a short list in models/src/effects.rs, IDENTITY_EFFECTS, that cancellation skips. Zap the basilisk and it loses FireImmune, because a species’ ordinary magic stays cancellable. The only difference between the two cases is whether the grant’s id is on that list. See “Parts nothing can cancel” in ../how-to/add-a-monster.md to build a creature that way.
Step 7: keep it or drop it
If you like the basilisk, leave it – and add a line about it to MANUAL.md’s bestiary, because documentation ships with the change that makes it true. If this was a dry run:
git checkout models/src/monsters.rs
What you actually learned
-
A species is a row, and the row is the only definition. Nothing else in the game enumerates creatures.
-
A creature’s special behaviour is a property it carries, named on the row. The systems look for the property and never for your monster, which is why
.grants(&[Grant::of::<FireImmune>()])is the whole of “fire does nothing to it”. -
A whole creature can be nothing but grants. The dog is a plain chaser plus its grants, one per behaviour, and the ones listed in
IDENTITY_EFFECTScannot be cancelled. -
Rarity and depth are two numbers on the row, relative to the rest of the table, and changing one changes nothing else.
-
The tables are data, so the tools work on them for free: the
-contentlisting,NIHILURK_SPAWN, the table tests and the save file all read the same row you edited.
The one thing a row cannot express is a behaviour that does not exist yet. If your creature needs to do something no effect covers – breathe a cone of frost, get in a mech – that is a new effect plus the mechanic behind it, and then a row that names it. Which is the next page.
Where to go next
add-your-first-item.md the same lesson, for the catalog ../how-to/add-a-monster.md the short version, as a recipe ../how-to/add-an-effect.md giving it a property nothing else has ../how-to/tune-rarity-and-depth.md weights and debut depths in full ../reference/content-tables.md every field of every table ../explanation/combat-and-balance.md what good numbers look like
Tutorial: add your first spell
Audience Anyone who wants to add content to nihilurk. You have
done `add-your-first-item.md`, or you are
comfortable enough to skip it.
Prerequisites A checkout, and a working `cargo`. That is all.
Time About ten minutes.
You will Add an active spell, aim it, feel it cost Magic,
then see where the pattern stops looking like an
item.
This is a lesson, not a recipe. When you want the short version, read ../how-to/add-a-spell.md.
%%{init: {'theme':'base','themeVariables':{
'primaryColor':'#20242b','primaryTextColor':'#d7dae0',
'primaryBorderColor':'#5c6370','lineColor':'#8a8f98',
'fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace',
'fontSize':'13px'}}}%%
flowchart LR
S1["1 · find<br/>SpellEffect"]:::cold --> S2["2 · add<br/>a row"]:::hero
S2 --> S3["3 · write<br/>the mechanic"]:::magic
S3 --> S4["4 · give it<br/>to yourself"]:::hero
S4 --> S5["5 · cast it"]:::peril
S5 --> S6["6 · where it<br/>isn't an item"]:::cold
classDef hero fill:#3a3418,stroke:#d7ba4a,color:#e8dfa8
classDef peril fill:#3a1f1f,stroke:#c05050,color:#f0c8c8
classDef magic fill:#2f2038,stroke:#a86fc0,color:#e6cdf0
classDef cold fill:#17323a,stroke:#4aa3c0,color:#bfe4f0
What you are about to learn
nihilurk teaches its active spells to the player at random, through a hero coin (an uncommon coin-table pickup), rather than starting anyone with any. A spell is coded the way a potion or a wand is – one identity enum, one catalog row, one mechanic keyed off it – because it is exactly as active as either of those. This tutorial adds one more, by hand, the way every spell in the table was first tried.
It is also, on purpose, not an item. It has no Position, no Item marker, no pack slot. It cannot be dropped, thrown, or found on a floor. It lives permanently in whoever’s Spellset it’s in, and it spends Magic instead of a battery.
By the end you will have added Ice Bolt, and you will know exactly which parts of “add an item” still apply and which ones stopped applying the moment you left Position off the struct.
Step 1: find the enum and the table
A spell’s identity is SpellEffect, in:
models/src/components.rs
Search for it:
pub enum SpellEffect {
DragonBreath,
Sting,
Thunderbolt,
// ... more variants ...
}
Next to it, in models/src/catalog.rs, is the table that gives each variant a name, a cost, a reach and a kind:
pub const SPELLS: &[SpellDef] = &[
SpellDef { effect: SpellEffect::DragonBreath, name: "Fireball", cost: 2, range: 8, kind: SpellKind::Attack },
// ... more rows ...
];
Read Fireball’s row:
SpellDef { effect: SpellEffect::DragonBreath, name: "Fireball", cost: 2, range: 8, kind: SpellKind::Attack }
| | | | |
the identity its label Magic reticle Attack or Skill --
spent reach a staff's business
when it's Attack
cost is Magic points, spent by spell_system the instant the spell actually fires (multiplied by a wielded staff’s TurboMagic, but only for an Attack). range feeds the aiming reticle exactly the way a wand’s Ranged.range does – you will aim Ice Bolt the same way you’d zap a wand of cold. kind is read once, by spell_cost and by whatever mechanic you write in step 3.
Step 2: add a row
Ice Bolt is Fireball’s cold twin. Append a variant at the very end of the enum – never in the middle, since a save encodes a SpellEffect as its position:
pub enum SpellEffect {
DragonBreath,
// ... the other variants ...
GateDown,
IceBolt,
}
And a row anywhere in SPELLS (the table’s order doesn’t matter, only the enum’s does):
SpellDef { effect: SpellEffect::IceBolt, name: "Ice Bolt", cost: 2, range: 6, kind: SpellKind::Attack },
Build:
cargo build
It will not compile. That is the point of the next step – unlike a weapon or a suit of armour, a spell’s row is not the whole of it.
Step 3: write the mechanic
The error names a missing match arm in models/src/items/spells.rs:
fn apply_spell_effect(
world: &mut World,
user: Entity,
target: Position,
effect: SpellEffect,
power_mult: i32,
) {
match effect {
SpellEffect::DragonBreath => breathe_fire(world, user, target, power_mult),
// ... more arms ...
}
}
The match has no catch-all, on purpose – the same discipline apply_wand_effect holds over WandEffect. A variant that compiled without an arm would be a spell that silently did nothing, and nihilurk does not ship those. power_mult is constants::spells::TURBO_MAGIC_POWER_MULT when a staff multiplied an Attack’s cost, 1 otherwise – fold it into your damage the way every existing Attack arm does. The staff gives back more damage than it takes in Magic; both multipliers live in constants::spells, one beside the other, so the trade stays readable.
Add the arm, and the function it calls:
SpellEffect::DragonBreath => breathe_fire(world, user, target, power_mult),
SpellEffect::IceBolt => ice_bolt(world, user, target, power_mult),
fn ice_bolt(world: &mut World, user: Entity, target: Position, power_mult: i32) {
let (power, power_bonus) = {
let fighter = world.get::<Fighter>(user);
let loadout = loadout(world, user);
(
fighter.map_or(0, |f| f.power) + loadout.power_die,
fighter.map_or(0, |f| f.power_bonus) + loadout.power_bonus,
)
};
let damage = ((world.resource_mut::<GameRng>().0.gen_range(1..=power.max(1)) + power_bonus)
.max(0))
* power_mult;
world
.resource_mut::<GameLog>()
.add("A wave of killing frost radiates outward!".to_string());
elemental_blast(
world,
Some(user),
target,
BLAST_RADIUS,
damage,
Some(Element::Cold),
BlastPalette::Frost,
);
}
Look closely and you’ll notice you wrote almost nothing. elemental_blast is the same function a zapped wand of cold calls – the disc, the line-of-sight check, the animation, the chain reaction into any trap or coin caught in it, all of it borrowed. The only thing ice_bolt supplies is how much damage, computed from the caster’s own claws rather than a wand’s dice. That computation is copied verbatim from breathe_fire right above it; a spell that inflicts a status instead of damage would instead borrow from crate::conditions, the way a wand’s utility effects do.
Build again:
cargo build
It compiles. cargo test has one more exhaustive match: every_spell_has_a_catalog_row in models/tests/it/creature_casts.rs lists every spell, so add SpellEffect::IceBolt to its match and to its all array.
Step 4: give it to yourself
There is no NIHILURK_SPAWN for a spell – there is no entity to spawn. In play, the only way to learn one is a hero coin (an uncommon coin-table pickup that teaches a random not-yet-known spell); to try Ice Bolt without hunting for a coin, replace the empty Spellset::default() in initialize_world (models/src/map/levels.rs) with:
Spellset {
slots: vec![SpellEffect::IceBolt],
},
Tests about the lurk, the hero coin and amnesia expect an empty starting Spellset, so cargo test fails while this edit stands. Step 7 reverts it.
Build and run:
cargo build
cargo run -p nihilurk
Step 5: cast it
Press Z. You should see a small box listing Ice Bolt and its Magic cost. Press a (or confirm the highlighted row) to pick it, and the aiming reticle opens exactly as it would for a wand.
Rows are lettered a to d, the way the pack’s are, and the menu is the only way to a spell – no key fires a slot directly. While the reticle is up, Tab snaps it to the next monster or item in view instead of nudging it one tile at a time.
Confirm the shot at something, and watch your Magic drop by two. Try it again with an empty pool: the reticle never even opens, and the log says why.
Step 6: where it stops being an item
Try the things that work on a potion, a wand, a dagger:
- Open the pack (
i) and look for Ice Bolt. It isn’t there. A spell never enters aBackpack; the player’s copy of it lives only inSpellset. - Try to throw it (
t). There is nothing to select – the throw menu only ever listsBackpackcontents, and a spell was never in one. - Save and reload. Your Magic total survives, and so does your
Spellset–models/src/saveload.rsround-trips it like every other piece of the player. If Ice Bolt ever left a marker of its own lying around on you (the way the spell Magic Ward or Bide does), that marker would need a row in theEFFECTSregistry and aneffects::lendto put it there – see../how-to/add-a-spell.mdfor the two that already do. - Give a monster the same trick.
SpellEffectis a shared mechanic –ice_boltdoesn’t care whouseris – and a monster casts from its spellset out of its ownMagic. Chain.casts(3, &[SpellEffect::IceBolt])on a bestiary row, and that creature is born with the spell and the Ma for three casts of it, and its rule set will pick it and fire it at you until the Ma runs out. The dragon’s Fireball is exactly that. See../reference/agents.md.
None of that is a bug in what you built. It’s the shape of the thing: a spell is a mechanic on loan to whoever’s Spellset names it, not a possession with a life of its own on the floor.
Step 7: keep it or drop it
If you like Ice Bolt, leave it. If this was a dry run:
git checkout models/src/components.rs models/src/catalog.rs \
models/src/items/spells.rs models/src/map/levels.rs
What you actually learned
-
A spell is identified the same way a potion or a wand is: one enum variant, one catalog row, one arm of an exhaustive match that will not let a variant ship silent.
-
Almost all of a spell’s behaviour is borrowed from whatever already does the thing –
elemental_blastfor a blast,crate::conditionsfor a status – the same reuse a wand’s own mechanic leans on. -
A spell is not an item, deliberately: no
Position, noItem, no pack slot, noNIHILURK_SPAWN, nothing to throw or drop. It lives in aSpellset, and it costs Magic instead of running out of charges. -
A
SpellEffect’s mechanic is shared and reusable — a monster can do the same trick, as the dragon does..casts(...)on a bestiary row gives it the spell and the Magic to pay for it, and its rule set does the triggering.
Where to go next
../how-to/add-a-spell.md the short version, as a recipe
add-your-first-item.md the same lesson, for items
../how-to/add-an-item.md the pattern this one borrows
../reference/input-and-turn-loop.md where spell_system sits in the schedule
Tutorial: give a monster a mind
Audience Anyone who has added a monster and now wants it to
behave differently, not just hit harder.
Prerequisites `add-your-first-monster.md`, or the same comfort with
the bestiary.
Time About twenty minutes.
You will Write one rule and one rule set, give them to the
emu, test them on a percept you build by hand, and
watch an emu run from you.
This is a lesson, not a recipe. When you want the short versions, read ../how-to/add-a-rule.md and ../how-to/add-a-rule-set.md.
What you are about to learn
A monster does not have behaviour code. Each turn it is handed a Percept (what is true right now, around it) and a RuleSet (an ordered list of rules). The first rule that fires decides the turn. That is all a mind is here. You will write a coward: an emu that fights anything next to it, but bolts the moment you come within three tiles, and otherwise stands still.
Step 1: look at a mind that already exists
Open models/src/agents.rs and find CHASER:
pub static CHASER: RuleSet = RuleSet {
name: "chaser",
leashed: true,
rules: &[CAST, SHOOT, STRIKE, CLOSE_IN],
};
Read it as a sentence: cast a spell if it can, else shoot if it can, else strike what is next to it, else walk at the nearest foe, which is the player unless one of their allies stands nearer. Every emu on the floor thinks exactly that today.
Step 2: write the rule
There is no rule for “run when close”, so write one. Next to flee, add:
fn bolt_when_close(p: &Percept) -> Option<Action> {
let player = p.foes.iter().find(|f| f.is_player)?;
if chebyshev(p.at, player.at) > 3 {
return None;
}
let (dx, dy) = toward(p.at, player.at);
Some(Action::Step(-dx, -dy))
}
/// A noticed player within three tiles: step straight away from them.
pub const BOLT_WHEN_CLOSE: Rule = Rule {
name: "bolt when close",
fire: bolt_when_close,
};
Two things to notice. The rule reads only p: it never touches the world. And p.foes holds the player only once the emu has noticed them, so a player with a ring of stealth walks up to a coward unchased.
Step 3: write the set
With the other sets:
/// Bites what is next to it, bolts from a player who comes close,
/// and otherwise stands its ground.
pub static COWARD: RuleSet = RuleSet {
name: "coward",
leashed: false,
rules: &[STRIKE, BOLT_WHEN_CLOSE],
};
The order is the personality. STRIKE first means a cornered coward still bites. Swap the two and it would rather run than bite, even with you standing on its toes.
Step 4: test it before you play it
In the tests module at the bottom of agents.rs:
#[test]
fn a_coward_bolts_then_bites_when_cornered() {
let map = room();
let e = entities(1);
let mut p = percept(&map, (8, 6), (10, 6));
p.foes = vec![sighting(e[0], (10, 6), true)];
assert_eq!(think(&p, &COWARD), Action::Step(-1, 0));
p.foes = vec![sighting(e[0], (9, 6), true)];
assert_eq!(think(&p, &COWARD), Action::Strike(e[0]));
}
cargo test -p nihilurk-models --lib coward
No world was built. That is the point of a mind made of plain functions: the test is three lines of percept.
Step 5: give the emu the mind
A set is picked by the monster’s tactic. Add a variant at the end of MovementType in models/src/components.rs (a save writes it by position):
Cower,
Then build. The compiler lists every match that needs to hear about it. The one that matters is rule_set_for in agents.rs:
MovementType::Cower => &COWARD,
In models/src/monsters.rs, change the emu’s movement from Chase to Cower, and add Cower to the use MovementType::{...} line at the top.
Step 6: meet it
NIHILURK_SPAWN="emu" cargo run -p nihilurk
Walk toward it. At three tiles it runs. Corner it and it bites. Step back past three and it stops and watches. Walk out of the room: it does nothing at all, because a mob off your view never acts.
Step 7: keep it, or put it back
git checkout models/src
If you keep it, add the rule and the set to ../reference/agents.md.
See also
../reference/agents.md every rule, every set ../explanation/agents.md why a mind is a list
How to add a new playable body
Audience Engine developer.
Prerequisites ../explanation/ecs-in-nihilurk.md, ../how-to/work-with-the-ecs.md.
../tutorial/add-your-first-body.md is the long version,
as a lesson, if you want to feel the save-marker bug first.
Result A third hand-written `Body::Variant`, selectable with
`-b <name>`, that survives a save/load round trip.
Not a content-table recipe. -am <species> already lets anyone wear a
bestiary row (add-a-monster.md is that whole story) — this page is for the
other kind of body, one written by hand the way the lurk was: its own dice,
its own kit, its own rules about gear. Reach for this only when a species
from -am genuinely won’t do.
The table
models/src/body.rs -> enum Body
Three variants today: Nihil (the default), Lurk, Monster(&'static MonsterDef). One enum, not a flag per body, because a player is exactly one
of them and the argument parser has nothing to cross-check — see the doc
comment at the top of body.rs before you touch it.
The recipe
Follow wear_lurk line for line; a fourth body is a sibling of it, not a
new shape.
-
Add the variant.
pub enum Body { Nihil, Lurk, Wraithkin, // new Monster(&'static MonsterDef), }Body::name()needs an arm too — it is what-bmatches against and what an error about the body prints. -
Write
wear_wraithkin, next towear_lurk. It inserts the startingRenderable,Fighter,MagicandSpeedthe same way, and grants whatever innate effects the body is born with throughgrant_all— aconst &[Grant]slice, the same shapeLURK_GRANTSuses, so it can be handed togrant_allunchanged. Pull every number out to its ownconstants::wraithkinmodule (see../reference/constants.mdbelow) rather than writing them inline — that is the one linewear_lurkitself would fail review on if it didn’t. -
Wire it into
wear:Body::Wraithkin => wear_wraithkin(world, player), -
Give it a marker that survives a save. A monster body is the one body the save writes down (
SaveGame::monster_body, the species id); the lurk is read back from its ownLurkeffect marker, which rides the effect ledger like anything else a creature was born with. Your body needs the same: an effect (or existing marker) that (a) is already saved, and (b) nothing else in the game would attach to the player by accident. A new saved field changes the save format: see theEntitySavenote inadd-an-item-category.md. -
Teach
innate_tempoif the body doesn’t return toSpeedKind::Normalafter a staircase lifts whatever the floor lent it (the lurk always returns toQuick). -
Teach
equip_refusalif the body can’t wear everything nihil can. The lurk refuses every slot butSlot::Finger; a monster body refuses everything unless its bestiary row carriesItemUser. Say why in the refusal string — it’s what the player reads when they try. -
Wire the flag.
engine/src/main.rsmatches("-b", "nihil")/("-b", "lurk")literally; add("-b", "wraithkin") => body = Some((models::Body::Wraithkin, "-b")).-band-amalready refuse being passed together and refuse an unrecognised name — nothing else to touch there. -
brings_a_pack. OnlyNihilreturnstrue. Leave it that way unless the new body is meant to start holding a mace, which none of the hand-written ones are — the whole point is a body with nothing to buy.
Checks that will catch you
models/tests/it/body.rs and lurk.rs are the pattern to copy for a new
wraithkin.rs, declared as mod wraithkin; in models/tests/it/main.rs
and run with cargo test --test it wraithkin::. Build a World with
StartingBody(Body::Wraithkin),
call initialize_world, then assert the glyph, the dice, the grants, and
that a save/load round trip comes back as the same body. Nothing else in the
suite exercises a body that doesn’t exist yet.
cargo test --test it content:: won’t see this at all — a hand-written body is
not a table row, so none of its checks apply. That absence is the tell that
you’re outside the tables and should be moving carefully, not the sign that
you can skip testing.
Ship it with the docs that describe it
Every one of these said something specific about the lurk when it landed, and will say something wrong about your body if you don’t touch it:
../reference/cli-and-env.md— the-b <body>section spells out each body’s dice, kit and quirks for the player.../reference/constants.md— a row in the modules table for the newconstants::<name>module.../reference/components.md— the “what the player is” section, if the new marker or resource is worth a reader reaching for.MANUAL.md/doc/nihilurk.6— the shipped manual page.
Appendix: quick check
- Add the
Bodyvariant inmodels/src/body.rs, and its arm inBody::name(). - Write
wear_<name>next towear_lurk, with every number in a newconstants::<name>module. - Add the
Body::<Name> => wear_<name>(world, player)arm inwear. - Give it a save marker: an effect already in
EFFECTSthat nothing else puts on the player. - Teach
innate_tempoif the body does not return toSpeedKind::Normal. - Teach
equip_refusalif the body cannot wear everything, and say why in the string. - Add the
("-b", "<name>")arm inengine/src/main.rs. - Leave
brings_a_packreturningfalse. - Copy
models/tests/it/body.rsandlurk.rsinto a new test file named for the body: glyph, dice, grants, save round trip. - Run that test with
cargo test --test <file>, thencargo test. - Fix the docs:
update-the-docs.md, “A playable body”.
See also
../tutorial/add-your-first-body.md the long version, as a lesson
../reference/cli-and-env.md the player-facing story, -b and -am both
add-a-monster.md the other way to play as something else
add-an-effect.md writing the Grants a body is born with
../reference/components.md Body, MonsterBody, StartingBody
../explanation/combat-and-balance.md picking dice that don’t need gear to matter
How to add a monster
Audience Content author.
Prerequisites You have done `../tutorial/add-your-first-item.md`,
or you are comfortable enough to skip it.
Result A new species that populates floors, can be
polymorphed into, can be created by a scroll, and
can be played as with `-am <name>`.
One line in one table. There is no second place.
The table
models/src/monsters.rs -> BESTIARY
Every creature in the game is a row in it. Level population, the scroll of create monster, the wand of polymorph and the scroll of vorpalize weapon all draw from this table, so a row is a species everywhere at once.
The recipe
-
Open
models/src/monsters.rsand findBESTIARY. -
Copy a row that is close to what you want and change it. A basilisk, say – slow, armoured, nasty, and not something floor 1 should meet:
MonsterDef::row("basilisk", 'b', Color::DarkGreen, Chase, 5, 10, 1, 10, 2, 5),The arguments, in order:
name "basilisk" unique, lowercase, displayed glyph 'b' one character on the map color Color::DarkGreen see the palette note below movement Chase Static | Chase | Ambush | Flee | Confused picks its rule set: see ../reference/agents.md hp 5 total hit points power 10 attack die: damage rolls 1d10 power_bonus 1 flat, added once to that roll armor 10 defence die: rolls 1d10 armor_bonus 2 flat, added once to that roll min_depth 5 first floor it can appear on -
Build and look at it:
cargo build NIHILURK_SPAWN="basilisk" cargo run -p nihilurk -
Run the table tests, which will now include your row:
cargo test --test it content::
That is the whole procedure. Nothing else in the codebase needs to be told that basilisks exist.
The row you just wrote is also the whole recipe for playing as one:
-am basilisk puts a player inside it, dice and innate magic and all –
see ../reference/cli-and-env.md, “-am <species>”. Nothing about that
is a special case for the player; it is the same row a floor-5 basilisk is
built from. If what you actually want is a hand-written playable body,
with its own kit and its own rules rather than a bestiary row worn as a
costume, that is add-a-body.md instead.
Choosing the numbers
Copy the stat band your creature belongs to:
fodder hp 1-2 power 4-8 armor 4-8 min_depth 1
mid hp 3-6 power 6-10 armor 6-10 min_depth 5
deep hp 8-12 power 8-12 armor 6-10 min_depth 10
Raise armor to make it hard to kill, power to make it frightening to stand next to, hp only to buy it one more exchange. Why that is so – and why nothing in the bestiary has thirty hit points – is ../explanation/combat-and-balance.md.
min_depth in use is 1, 5 and 10. Nothing stops you using 2 or 11.
Optional extras, chained on
Anything past the ten numbers is chained, so a plain creature stays one readable line.
Innate magic – attach effect components at birth:
MonsterDef::row("basilisk", ...).grants(&[Grant::of::<ColdImmune>()]),
The effect must already exist. See add-an-effect.md. There is a shorthand for the common one – .grants(ITEM_USER) – which makes a creature clever enough to catch thrown gear, wear it, and read scrolls that land on it.
Spells – a Spellset and the Magic to pay for it (the dragon):
MonsterDef::row("basilisk", ...).casts(3, &[SpellEffect::Thunderbolt]),
The first number is how many casts of its dearest spell the creature has the Ma for (MonsterDef::magic_pool): two for the beefy, three for the weak. The pool never refills; each cast spends the spell’s SpellDef::cost, and a dry caster goes back to its claws. A player wearing the row gets the spells in the spell bar.
Parts nothing can cancel – what the creature is, as opposed to magic it has. A wand of cancellation strips everything in .grants(...) except effects whose id is in IDENTITY_EFFECTS, so give the row such effects and cancellation leaves them. The dog is built this way:
MonsterDef::row("dog", ...).grants(DOG_GRANTS),
DOG_GRANTS is five grants, one per behaviour (any treat tames it, a charm makes it the Helper, it outranks the ordinary Helper, a kill may reshape it, and its death reveals a faerie shapeshifter with no gore), and all five are identity effects. The (d) is a (d)oppelganger, or rather a dogppelganger: a faerie shapeshifter wearing a dog. That is why nothing cancels what makes it a dog (bar its shapeshifting), why a kill can change its shape, and why no dog ever dies. What “dies” was never a dog. A shapeshift keeps whichever of them the old body held (monsters::reshape). To build a creature like that, write one effect per behaviour (add-an-effect.md, “An effect nothing can cancel”), list their ids in IDENTITY_EFFECTS, and grant them from the row. Keep the behaviours separate: one grant per atomic behaviour, so each can be given and tested on its own.
Born invisible – unseeable without see-invisible (the phantom):
MonsterDef::row("basilisk", ...).invisible(),
Rarity – how often it is drawn against the rest of the floor’s eligible pool. The default is DEFAULT_SPAWN_WEIGHT; leave it alone unless you mean it:
MonsterDef::row("basilisk", ...).weight(3),
Chains combine in any order:
MonsterDef::row("basilisk", 'b', Color::DarkGreen, Chase,
5, 10, 1, 10, 2, 5)
.grants(&[Grant::of::<ColdImmune>()])
.weight(4),
Do not write
Aggravatedin a row. It is a fifthMovementType, applied at run time by the scroll of aggravate monsters. The four legal values for a row are the ones listed above; what each does is in../reference/content-tables.md.
Only 16 colours survive a save. The file packs a colour into one byte against a fixed palette; anything off it – an RGB value, a 256-colour index – draws correctly and reloads as White. The palette is listed in
../reference/content-tables.md.
Renaming a row breaks old saves. A saved monster stores its row’s id (the
name) and looks its species back up on load. Rename"basilisk"and every basilisk in an existing save comes back without its innate magic. Adding and removing rows is safe; renaming is not.
Checks that will catch you
cargo test --test it content:: fails if:
- two rows share a name (
every_content_name_is_unique) - your row cannot be built by name (
every_content_name_spawns_and_...) - a species turns up above its
min_depth(a_species_never_appears_above_its_min_depth)
Glyph collisions are not checked. Two creatures may share a letter; the existing bestiary uses one letter per species by convention, and D is already the dragon’s.
Appendix: quick check
- Copy the closest row in
BESTIARY(models/src/monsters.rs) and change it. - Give it a unique lowercase name, a stat band from “Choosing the numbers”, and a
min_depth. - Chain extras only if you need them:
.grants(..),.casts(..),.invisible(),.weight(..). - Pick a colour from the sixteen in the palette, and never write
Aggravatedas the movement. - Never rename an existing row; saved monsters look their species up by id (the
name). - Run
NIHILURK_SPAWN="<name>" cargo run -p nihilurkand look at it. - Run
cargo test --test it content::. - Fix the docs: nothing, unless the row brings a new mechanic (
update-the-docs.md).
See also
../tutorial/add-your-first-monster.md the long version, as a lesson
add-a-body.md a hand-written body instead of a bestiary row add-an-effect.md give it a property nothing else has tune-rarity-and-depth.md where and how often it shows up ../reference/content-tables.md every field, every type, the palette ../reference/spawn-api.md MonsterDef::pick, spawn_monster ../explanation/combat-and-balance.md what the numbers mean
How to add a rule set
Audience Engine developer.
Prerequisites `../reference/agents.md`. For a rule that does not
exist yet, `add-a-rule.md` first.
Result A new way for a creature to think, picked by its
tactic.
A rule set is an ordered list of rules. The first one that fires takes the turn.
Step 1: write the set
In models/src/agents.rs, with the other sets:
/// Keeps its distance and shoots; never closes in.
pub static SKIRMISHER: RuleSet = RuleSet {
name: "skirmisher",
leashed: false,
rules: &[SHOOT, KEEP_DISTANCE, STRIKE],
};
Order is the behaviour. Write the set as a sentence first (“shoot if it can, otherwise back off, otherwise bite”) and list the rules in that order.
leashed: true keeps it from stepping out of a room into a doorway or a corridor. Only a set that hunts the player wants it.
Step 2: give it a tactic
A set is picked from the monster’s MovementType in rule_set_for. Add a variant at the end of the enum in models/src/components.rs (a save writes it by position), then one arm:
MovementType::Skirmish => &SKIRMISHER,
The compiler will not build until every match on MovementType has the new arm. Those are the places that still read the tactic directly (the renderer’s tints, the garrote).
Step 3: use it
Put the variant on a bestiary row’s movement column (add-a-monster.md).
Step 4: test it
A unit test in agents.rs per rule the set lists, built on percept(...): one where the rule should win and the ones before it should pass.
Verify
cargo test -p nihilurk-models --lib agents
NIHILURK_SPAWN="your monster" cargo run -p nihilurk
Then add the set’s row to the sets table in ../reference/agents.md.
Appendix: quick check
- Write
pub static <NAME>: RuleSetinmodels/src/agents.rs; the order ofrulesis the behaviour. - Set
leashed: trueonly for a set that hunts the player. - Append a
MovementTypevariant at the end inmodels/src/components.rs, and add its arm torule_set_for. - Fix every other
matchonMovementTypethe compiler names. - Put the variant in the
movementcolumn of a bestiary row. - Test each rule the set lists: one case where it wins, one where an earlier rule passes.
- Run
cargo test -p nihilurk-models --lib agentsandNIHILURK_SPAWN="<monster>" cargo run -p nihilurk. - Add the set’s row to the sets table in
../reference/agents.md.
See also
add-a-rule.md when no rule does what you need ../reference/agents.md every set, and what picks it ../tutorial/give-a-monster-a-mind.md the same thing, taught slowly
How to add a rule
Audience Engine developer.
Prerequisites `../reference/agents.md`.
Result A new reflex any rule set can list.
A rule is a condition on the percept and the action it calls for, in one function. It answers None when its condition does not hold.
Step 1: write the function
In models/src/agents.rs, next to the other rule functions:
fn keep_distance(p: &Percept) -> Option<Action> {
let near = p.foes.iter().find(|f| chebyshev(p.at, f.at) <= 2)?;
let (dx, dy) = toward(p.at, near.at);
Some(Action::Step(-dx, -dy))
}
Read only the percept. If the rule needs something the percept does not carry, add a field to Percept and fill it in ai::perceive. Never reach into the world from a rule: that is what keeps every rule testable on a hand-built percept.
If the rule needs chance, read p.roll. Do not roll your own.
Step 2: name it
/// A foe within two tiles: step straight away from the nearest.
pub const KEEP_DISTANCE: Rule = Rule {
name: "keep distance",
fire: keep_distance,
};
The doc comment is the rule’s one-line “fires when”: copy its shape from the others.
Step 3: list it in a set
A rule does nothing until a set lists it. See add-a-rule-set.md.
Step 4: test it
In the tests module of agents.rs, build a percept with percept(...), set the fields your rule reads, and assert what think returns for a set that lists it. Test both sides: once where it fires, once where it passes.
Verify
cargo test -p nihilurk-models --lib agents
Then add the rule’s row to the rules table in ../reference/agents.md.
Appendix: quick check
- Write
fn <name>(p: &Percept) -> Option<Action>inmodels/src/agents.rs; read only the percept, and take chance fromp.roll. - Name it as a
pub const <NAME>: Rule, with a one-line “fires when” doc comment. - List it in a rule set (
add-a-rule-set.md). - Test both sides on a hand-built
percept(...): once where it fires, once where it passes. - Run
cargo test -p nihilurk-models --lib agents. - Add the rule’s row to the rules table in
../reference/agents.md.
See also
add-a-rule-set.md putting the rule to work ../reference/agents.md every rule that exists already ../explanation/agents.md why a rule may only read the percept
How to add a spell
Audience Content author.
Prerequisites You know where `models/src/catalog.rs` is. If not,
do `../tutorial/add-your-first-spell.md` first.
Result A new active ability the player can trigger from
the `Z` menu, that costs Magic -- an
aimed **attack** like a wand, or a self/room-wide
**skill** like a potion or a scroll.
One enum variant, one catalog row, one arm of an exhaustive match. The same shape as a potion, a scroll or a wand – with one difference worth saying up front, and a fork partway through depending on which of the two you’re building.
A spell is not an item
Every other thing you can pick up in nihilurk is an entity: it has a Position on the floor, an Item marker, a pack slot, and a fate when you drop or throw it. A spell has none of that. It is never spawned, never lies on a floor, and cannot be dropped or thrown – there is nothing to drop.
A spell lives as a bare SpellEffect value inside a creature’s Spellset component:
pub struct Spellset {
pub slots: Vec<SpellEffect>,
}
Today only the player has one, taught in play by a hero coin (an uncommon coin-table row that puts a random, not-yet-known spell straight into the taker’s Spellset, up to constants::spells::SPELLSET_CAP – four) rather than started with any. Giving a spell to the player for testing without going through the drop table means putting it in that Vec by hand – there is no NIHILURK_SPAWN for this, because there is no entity to spawn. See “Hold it in your hands” below.
The other consequence of not being an item: a spell costs Magic, not a battery. SpellDef::cost is spent by spell_system every time the player triggers it. A monster that does the same trick under its own steam – the dragon’s fireball, the eel’s thunderbolt – has a Spellset and a Magic pool of its own, put there by its bestiary row (MonsterDef::casts). It pays SpellDef::cost out of that pool through the same spell_cost the player does (pay_for_spell), and it never refills.
The recipe
Two steps are the same whatever you’re building; then the path forks on SpellKind.
-
Append a variant to
SpellEffectinmodels/src/components.rs– at the end, never in the middle (see the warning below):pub enum SpellEffect { DragonBreath, // ... more variants ... GateDown, IceBolt, } -
Add the row in
SPELLS(models/src/catalog.rs):SpellDef { effect: SpellEffect::IceBolt, name: "Ice Bolt", cost: 2, range: 6, kind: SpellKind::Attack },costis Magic points, multiplied byconstants::spells::TURBO_MAGIC_COST_MULTwhen a staff (TurboMagic) is wielded andkindisSpellKind::Attack, never for aSpellKind::Skill.costis also calibrated againstconstants::player::START_MAGICand sits in one of four Magic-cost tiers, 1 through 4; the rows already inSPELLSshow where each tier lands.rangefeeds the aiming reticle exactly like a wand’sRanged, and is meaningless – leave it0– for a spell you’re about to make a Skill.List the spell in
every_spell_has_a_catalog_row(models/tests/it/creature_casts.rs), in itsmatchand in itsallarray.cargo testwill not build until you do.
Which of the two you’re building decides everything from here.
Create an attack
An Attack opens the aiming reticle, deals damage (or a status a target can shrug off, like Thunderbolt’s paralyse), and is the one TurboMagic touches at all: a staff multiplies its cost by constants::spells::TURBO_MAGIC_COST_MULT and its damage by the larger TURBO_MAGIC_POWER_MULT, which is what makes the staff worth the Magic.
-
Write the mechanic as one arm of
apply_spell_effect(models/src/items/spells.rs):fn apply_spell_effect(world: &mut World, user: Entity, target: Position, effect: SpellEffect, power_mult: i32) { match effect { SpellEffect::DragonBreath => breathe_fire(world, user, target, power_mult), SpellEffect::IceBolt => ice_bolt(world, user, target, power_mult), } }The match has no catch-all, the same as
apply_wand_effectoverWandEffect– step 1’s new variant will not build until this step gives it an arm. The compiler names the function and the missing variant, so there is nothing to remember.power_multisconstants::spells::TURBO_MAGIC_POWER_MULTwhen a staff multiplied the cost,1otherwise – fold it into your damage roll (multiply the final number, the way every existingAttackarm does; seesting,thunderbolt,force_lancefor the shape when armour is or isn’t subtracted).Most of an attack’s mechanic is borrowed rather than written:
- A blast at a point –
breathe_fire,lux,meteor_strikeall callelemental_blast(items/wands.rs), the same function a zapped or thrown wand calls. Pick a radius (BLAST_RADIUSfor a zap-sized disc,GRENADE_RADIUSfor a thrown-sized one), a damage roll, and anElement(orNonefor non-elemental). - A line –
force_lancetracesget_line(user_pos, target)by hand, stopping at the first wall, and rolls damage per creature it crosses. Copy it when the flavour is “a bolt” or “a beam” rather than “an explosion”. - A single target hit like a trap or a wand bolt –
sting(the dart trap’s own formula, aimed rather than laid) andthunderbolt(a plain armour-ignoring roll, like a wand bolt) both just callcrate::helpers::monster_at(world, target)and roll once. - Everything in view, no target needed –
circle_of_deathandfrost_novacallcrate::helpers::hostiles_in_view(world, user)instead of aiming at a point; pair this shape with step 4’s “no target” answer below.
Whichever shape you pick, land the damage through
helpers::apply_hitwith aHit::magicorHit::elementalrather than writing to aFighter’shpyourself – that one call is what makes Magic Ward, elemental immunity and a petrified victim’s damage cap apply to your spell. See “Play fair with Magic Ward” below. - A blast at a point –
-
Say whether it needs a target. Every attack above that hits one aimed tile does;
SpellEffect::needs_target(models/src/components.rs) lists the ones that don’t, because they hit everyone in view instead (Circle of Death, Frost Nova) – add your variant there if that’s the shape you picked in step 3, the same wayWandEffect::needs_targetcarves the wand of light out from every other wand. Leave it out (needing a target is the default) for anything aimed.
Create a skill
A Skill never opens on damage. It changes the caster (Cure, Heal, Bide, Magic Ward, Haste Self), the floor around them (Setup), or borrows a scroll’s own effect outright (Identify, Magic Mapping) – and TurboMagic never touches its cost.
-
Decide if it needs a target at all. Almost none do – a skill works on the caster or on everyone in view, the same as a room-wide attack. Add your variant to
SpellEffect::needs_target’sfalselist (models/src/components.rs) unless you are building the rare aimed skill (Polymorph Other is the one today:kind: Skillwith arange, so a staff does not double its cost; treattargetas step 3 of “Create an attack” would). -
Write the mechanic, the same match arm as an attack, but built one of two ways:
-
Borrow a scroll wholesale. Identify and Magic Mapping are one line each:
SpellEffect::Identify => super::scrolls::apply_scroll_effect(world, user, ScrollEffect::Identify),Reach for this whenever an existing scroll already does exactly what you want – a spell is a delivery method, not a reason to write the mechanic twice.
-
Write it fresh, reaching into
crate::conditionsfor anything that changes the caster’s state (cure_one_condition,hasten,paralyse, …) the waycure_selfandhaste_selfdo, or lending a marker component througheffects::lendthe waymagic_wardlendsMagicWardforLifetime::FloorandbidelendsBidedforLifetime::NextAction. A skill that plants something on the floor (Setup) reaches intocrate::trapsinstead – seesetupfor spawning a revealed trap and springing it immediately if something is already standing there.
power_multis still a parameter every arm takes, by convention (every arm in the match has the same signature) – aSkillis free to ignore it, sincespell_systemnever doubles aSkill’s cost. -
Play fair with Magic Ward
Any damage your spell deals to another creature goes through helpers::apply_hit, the same way every existing damaging spell does:
if apply_hit(world, victim, Hit::magic(damage), Some(&line)) == 0 {
return; // or `continue`, in a loop over several victims
}
Hit::magic (no element) and Hit::elemental (fire, cold, …) both set the hit’s magical flag, and apply_hit reads it: a warded victim takes nothing, the log says so, and wands::ward_ricochet plays the bounce. Hit::physical is the opt-out – steel, wood and gravity, which a ward is no help against.
No spell checks MagicWard by hand, and none should. apply_hit owns that question along with elemental immunity and the stone-chip cap, so routing through wands::elemental_blast gets all three for free – the blast calls apply_hit per victim itself. The only other ward check in the game is abilities::fire_on_hit, and it answers a different question: whether the trick riding on a landed blow takes hold. Roll damage straight onto a Fighter and your spell becomes the one hole in an otherwise complete shield, which is worse than it sounds: the player bought that immunity with three whole Magic points.
Hold it in your hands
There is no NIHILURK_SPAWN for a spell. In play the only way to learn one is a hero coin (see “A spell is not an item” above); to try Ice Bolt without hunting for a coin, replace the player’s starting Spellset::default(), in initialize_world (models/src/map/levels.rs), with:
Spellset {
slots: vec![SpellEffect::IceBolt],
},
Build, run, and press Z to see it listed, then its row letter to aim it, or Alt+Q/W/E/R for slots one to four. Tab while aiming snaps the reticle to the next thing in view.
If this was a dry run, git checkout models/src/map/levels.rs along with catalog.rs, components.rs and items/spells.rs.
Append enum variants; never insert or reorder them.
SpellEffectderivesSerialize, and a save encodes a variant as its position in the enum – including inside a savedSpellset(see the next warning). Adding one at the end is safe; putting one in the middle silently turns a saved spell into a different one, for every save that had it.
Spellsetdoes survive a save –models/src/saveload.rsround-trips it like every other piece of the player. So do the two marker components spells have left behind so far,MagicWardandBided– but not through a field of their own: each is a row in theEFFECTSregistry (models/src/effects.rs,"magic_ward" => MagicWard;) lent througheffects::lend, andEntitySave::effectssaves it by id with theLifetimeit is being held for. If your new spell leaves a marker of its own lying around outsideSpellset, register it there and lend it; a component inserted directly is the one a reload quietly forgets.
A monster casts from its spellset, out of its own Ma. Its rule set picks one of the spells in its
Spellsetthat itsMagiccan pay for, at random each turn, and fires it if it can (../reference/agents.md, thecastrule). To give a species your spell, chain.casts(n, &[SpellEffect::YourSpell])on its bestiary row – that is all the dragon’s Fireball is. Only anAttackspell with a range is ever fired this way; a skill never is. A player wearing the row (-am) gets the spells in the spell bar and pays from the player’s Ma.
Verify what you added
cargo build
cargo test --test it spells::
There is no cargo test --test it content:: coverage for spells the way there is for monsters and items – SPELLS is not part of spawn_named or content_names(), because nothing about a spell is spawned. models/tests/it/spells.rs pins a spell’s mechanic instead: it pushes a WantsToCast onto SpellQueue and runs spell_system against a dummy. Copy sting_bites_like_the_dart_trap_it_borrows_from if your spell has a number worth holding still.
Then prove it by hand: give it to the player, aim it, and watch the log line spell_system prints ("You cast ___!") followed by whatever apply_spell_effect logs itself.
Appendix: quick check
- Append a variant at the end of
SpellEffectinmodels/src/components.rs. - Add the
SpellDefrow toSPELLSinmodels/src/catalog.rs:cost,range(0for a skill),kind. List it inevery_spell_has_a_catalog_row(models/tests/it/creature_casts.rs). - Attack: write the arm in
apply_spell_effect(models/src/items/spells.rs), and land damage throughapply_hitwithHit::magicorHit::elemental. - Attack that hits everyone in view: add it to
SpellEffect::needs_target. - Skill: add it to the
needs_targetlist unless it is aimed, then write the arm, borrowing a scroll if one already does the job. - A marker the spell leaves behind: add it to
EFFECTSand lend it througheffects::lend. - Give it to a species with
.casts(n, &[SpellEffect::<Name>]); only a ranged attack is ever cast that way. - Try it: put it in the starting
Spellsetininitialize_world, pressZ, then revert that edit. - Run
cargo test --test it spells::. - Fix the docs: nothing, unless it adds a
constants::module (update-the-docs.md).
See also
../tutorial/add-your-first-spell.md the long version, as a lesson
add-an-item.md the pattern this one borrows
../reference/input-and-turn-loop.md where spell_system sits in the schedule
How to add a trap
Audience Content author.
Prerequisites Comfortable adding a row. See
`../tutorial/add-your-first-item.md`.
Result A new trap the dungeon lays on floors, that reveals
itself in the usual three ways, and that survives a
save.
Four edits, and the compiler enforces the last two.
The mechanics are one file:
models/src/traps.rs
The components a trap is built from – Trap, TrapEffect, TrapReveal, EntityMoved – are nouns and live in models/src/components.rs with everything else the ECS is made of. The three holds a trap can put on a victim (Asleep, Pinned, Rooted) are ordinary effects and live in models/src/effects.rs. You add a TrapEffect variant there; the row and the mechanic stay here.
The recipe
-
Append a variant to
TrapEffect(incomponents.rs):pub enum TrapEffect { ... Dart, Pit, // <- at the end; see the save-format warning } -
Add the row to
TRAPS(intraps.rs):TrapDef { effect: TrapEffect::Pit, name: "pit trap", glyph: '^', color: Color::Red, weight: 10, min_depth: 1 },The name is what the player sees once the trap is known – there is no separate label function to update.
weightis how often the dungeon lays this one against the others it could lay;min_depthis the first floor it appears on. Every existing trap is10and1, which is to say equally likely from the start. -
Write the mechanic as an arm of
apply_trap_effect:TrapEffect::Pit => pit_effect(world, victim, is_player, seen),apply_trap_effect’s match has no catch-all, so the compiler will refuse to build until you write this arm. That is deliberate: a trap that does nothing is not a trap. -
Say what it looks like as an arm of
trap_flourish:TrapEffect::Pit => { if let Some(mut fx) = world.get_resource_mut::<Particles>() { fx.poof(p.x, p.y, 0.0); } }Same deal: no catch-all, so the compiler refuses to build until the new trap answers “and what does it look like?”. A trap that springs with nothing on screen reads as a bug in the log. If the animation only makes sense once the mechanic has run – a teleport puffs on the tile the victim left – write an empty arm saying so and do it there, the way
TeleportandTrapdoordo.
Then:
cargo build
cargo test --test it content::
NIHILURK_SPAWN="pit trap" cargo run -p nihilurk
Writing the mechanic
Look at what the existing six do, and reuse the pieces:
trapdoor_effect moves the victim to the next floor down
snare_victim(...) Holds the victim for `TrapDef.snare_turns` turns
(bear trap pins the feet, gas takes the turn)
teleport_effect relocates the victim on this floor
arrow_effect damage, and drops a real arrow on a miss
dart_effect damage, plus a permanent bite of strength
If your trap snares, put the duration in the row’s snare_turns and let apply_trap_effect pass it through – no constant to add.
The arrow and dart also scale with depth. trap_damage_tier(depth) steps up at the depths in TRAP_DAMAGE_TIER_LAST_DEPTH, and it adds to the arrow’s roll and the dart’s drain. The bands and every per-tier amount are in constants::traps (TRAP_DAMAGE_TIER_LAST_DEPTH and friends). This band is the trap’s own – coarser than the floor-crowding map::difficulty_tier, on purpose.
Your arm receives:
world the ECS world
victim whoever the trap got -- may be a monster, not the player
is_player whether it was
seen whether the player can see this happen
trap_pos where the trap is (or was), if you need it
A trap fires for monsters too.
victimis whoever moved onto the tile. Write the log line through theseen/is_playerpair the way the existing arms do, or the player will read second-person messages about a bat.
A trap fires for whoever is near it too. Shoot a trap, or catch one in a wand’s blast, and
detonate_trapbursts it over the 3x3 around its tile: your arm then runs once per creature caught, none of whom is standing on the trap. So readvictim’s ownPositionif you need where they are, taketrap_posas where the trap was, and do not assume the two are the same tile. The burst’s own damage (TRICK_SHOT_*inconstants::traps) is dealt before your arm runs, and the trap entity is already despawned by then – an arm must never despawn it itself.spring_traphandles the stepped-on case; the bear trap’s “bites once” despawn lives there, not in the arm. Bursts also chain, so your arm can run while another trap’s burst is still resolving: re-read anything off the world that a burst could have changed, and never hold anEntityacross a call that can explode.
Damage traps ignore the armour die but not the armour bonus. If your trap deals damage, subtract
total_armor_plus(world, victim)and nothing else – seearrow_effect. Rolling a full opposed defence would make armour far better against traps than the design intends.
If your trap costs the victim turns, hold them: conditions::snare(world, victim, Grant::of::<Asleep>(), turns), or Pinned, or Rooted. Each is an ordinary effect held for Lifetime::Turns, aged down once per turn by effects::tick_effects and lifted when its clock runs out. Asleep forfeits the victim’s turn outright (player_incapacitated, and the AI skips the monster); Pinned only blocks movement – a swing still lands, a step is a bear_trap_thrash; Rooted is Pinned without the teeth. You do not have to implement any of that; you pick which hold and for how long.
The three run on their own clocks, so a victim can be asleep and pinned, and comes out of each when its own turns are up.
Reveal styles
Every trap rolls one of three discovery styles at spawn, with equal odds, and you do not choose it per row:
Sight shows itself as soon as its tile enters the viewshed
Adjacent stays hidden until the player is standing next to it
Triggered invisible until it goes off
nihilurk has no “search” action, so these three are the only ways a trap ever comes to light before it bites. If you want a trap that is always visible or never visible, that is a change to TrapBundle::random, not to a row.
Append
TrapEffectvariants; never insert or reorder. A saved trap stores its effect as a position in the enum. Inserting a variant in the middle turns every saved trapdoor into something else.
What you never have to do
- Give the trap a label.
TrapEffect::label()reads the row. - Add it to a list of trap kinds.
TrapDef::pickwalks the table. - Teach the save file about it, beyond the enum variant.
- Place it. Floor generation decides how many traps and where; the table decides which.
Appendix: quick check
- Append a variant at the end of
TrapEffectinmodels/src/components.rs. - Add the
TrapDefrow toTRAPSinmodels/src/traps.rs:weight,min_depth, andsnare_turnsif it holds. - Write the arm in
apply_trap_effect; log throughseenandis_player, and subtracttotal_armor_plusonly. - Write the arm in
trap_flourish, or an empty one that says why. - Run
cargo build, thencargo test --test it content::. - Run
NIHILURK_SPAWN="<name>" cargo run -p nihilurk. - Fix the docs: the
TrapEffectline in../reference/components.md(update-the-docs.md).
See also
tune-rarity-and-depth.md make a trap rare, or deep-only ../reference/content-tables.md the TrapDef fields ../reference/spawn-api.md TrapDef::pick, TrapBundle::from_def
How to add an effect
Audience Content author reaching past the tables, or an
engine developer.
Prerequisites You have added a row (`add-a-monster.md` or
`add-an-item.md`) and want it to do something no
existing component covers.
Result A property that a monster can be born with, a ring
can lend, a wand of cancellation can strip, and the
save file remembers.
An effect is a component on the creature, and both the dragon born fire-immune and the player wearing a ring of fire resistance carry the same one. (Why that is the whole design: ../explanation/data-driven-content.md.)
Everything here lives in:
models/src/effects.rs
Which kind do you need?
Marker A yes/no property. "Fire does nothing to it."
"It sees invisible things." Read as a query filter.
Modifier A number that stacks across everything equipped.
"+2 armour." "+2 on throws."
Cap A ceiling the dice cannot beat. "At most 1 damage in
melee." Folded with `min`, not `+`.
Markers are the common case. Take that branch unless your property is literally an integer that adds up, or a limit that overrides one.
There is one cap today (MeleeCap, carried by bows and crossbows) and it is read by one line of resolve_attack. It is written out by hand below the modifiers! rows rather than being one of them, because ceilings fold with min and the generated rows all fold with +. A second cap would be the moment to generalise that the way equipped_total is generalised over Modifier; see melee_cap in models/src/effects.rs.
Adding a marker effect
-
Declare the component in
models/src/effects.rs, next to its relatives. It must be a unit struct deriving exactly this set:/// Poison does nothing to this creature. #[derive(Component, Default, Clone, Copy)] pub struct PoisonImmune;Defaultis not optional – it is how aGrantattaches the thing without knowing its type. -
Add a row to
EFFECTS, pairing a stable id with the component:effects! { ... "fire_quarrel" => FireQuarrel, "poison_immune" => PoisonImmune, // <- your row }The id is what a save file stores. Write it out; never derive it from the type name, and never rename it once saves exist – the same rule a bestiary row lives by. Rows may be reordered and retired freely, because nothing about a row’s position means anything.
An effect that ends on its own gets the line the player reads when it does, in brackets after the type:
"asleep" => Asleep ["You shake off the drowsiness and come to."], -
Hand it out from a row. Any of these, or all of them:
// a species born with it MonsterDef::row("basilisk", ...).grants(&[Grant::of::<PoisonImmune>()]), // a ring that lends it while worn RingDef::new(RingEffect::PoisonResistance, "ring of poison resistance") .grants(&[Grant::of::<PoisonImmune>()]), // a launcher that lends it while held LauncherDef { name: "sling", color: ..., grants: &[Grant::of::<FireStone>()] }, -
Ask about it where it matters. It is an ordinary component, so:
// in a query Query<&Viewshed, With<SeesInvisible>> // ad hoc if world.get::<PoisonImmune>(target).is_some() { return; } // through the handle, when you only have a Grant grant.probe(world, entity)
That is the whole of it. Equipping and unequipping, saving and loading, and the wand of cancellation all work immediately, because all three walk EFFECTS rather than a list of special cases.
Never rename an id. The id, not the row’s position, is what a save file stores, so renaming one is renaming the thing on disk: every existing save comes back without that effect. Reordering rows and retiring them are both safe, and there is no ceiling on how many there can be – the
u64bitset that capped the list at 64 is gone.
An id a save names and the build has no row for is dropped, counted, and reported to the player in one line at the end of the load. Losing one property beats losing the run. This is what saving by name buys: a saved position would shift every later effect when a row retired, and nothing could tell, because every index would still be a valid index.
An effect missing from
EFFECTShalf-works. It will attach, and it will do its job for the rest of the session. It will not be saved, will not come back on load, and cannot be cancelled. This is the quiet failure to look for when an effect works until you reload.models/tests/it/abilities.rscatches it for anything armed by an ability row; nothing catches it for an effect only a mechanic reads.
Making an effect act on its own
Most effects are answers to a question something else asks. Some act by themselves, and ABILITIES in models/src/abilities.rs is all of those: one list, one row per ability, naming the Moment it fires at.
models/src/abilities.rs -> ABILITIES
Ability {
effect: Grant::of::<AggravatesMonsters>(),
when: Moment::EachTurn(AGGRAVATES_MONSTERS_CHANCE),
player_only: false,
action: |w, e, _| crate::items::aggravate_all_monsters(w, e),
flavour: Some("You yip! The whole floor turns your way."),
}
when is the moment. The two commonest:
Moment::EachTurn(chance)– while you carry it, something keeps happening: at this probability, on every turn the bearer acts.actionruns on the bearer and reports whether it actually did anything; a ring of regeneration wins its coin flip every other turn, and most of those turns there is nothing wrong with the player to mend, so it returnsfalseand stays silent.Moment::OnHit { glancing, lethal }– the attacker’s magic doing something to what it just hit.effectis the marker on the attacker,actionruns as(attacker, target), and the two flags are the only gating there is: does a glancing scrape count (acid says yes, a charm that needs skin says no), and does the killing blow count (there is no point charming a corpse).
Three rarer moments round out the enum: OnDamaged (the bearer was hurt and lived), OnStruck (a blow landed on the bearer, and the attacker is passed to action – spikemail’s spike_prick) and OnTargeted (the player turned their attention on the bearer, whether or not the blow that follows lands – a gorgon’s gaze). A choice – a dragon breathing fire instead of biting – is not a moment at all: it is a rule in the creature’s rule set (../reference/agents.md). Adding a moment nobody has yet is a Moment variant and one arm in whatever drives it; adding an ability at a moment that already exists is one row here.
flavour is logged only when the bearer is the player and action returned true, so write it in the second person. player_only marks a row as the player’s own trick – a monster that steals or catches the weapon behind it still fights the plain way.
The row names the effect with the same Grant handle everything else uses, so the ability never learns what granted it. A ring grants it today; a cursed blade could grant it tomorrow and the behaviour would follow, untouched.
ability_system drives EachTurn at the tail of the turn schedule (after ai, before visibility_system), which is deliberate: a passive that moves its bearer – teleportitis – lands the jump at the top of the bearer’s next turn, so the player sees where they ended up and acts from there before anything else moves. combat::resolve_attack fires every OnHit row for a hit that dealt damage and has no idea what is in it – which is why the aquator’s corrosion and a scroll of monster confusion’s charm are rows here rather than special cases in that function.
Making an effect fire once, when gear goes on
A Grant is a property held while the gear is worn. An event at the moment of wearing is a different component, and it is not in EFFECTS:
#[derive(Component, Clone, Copy)]
pub struct OnWear(pub fn(&mut World, Entity, Entity));
pub struct OnDoff(pub fn(&mut World, Entity, Entity));
equipment::toggle_equipped fires it with (wearer, item) after the item is worn, named and known, and the function owns everything that follows – including tagging the item Consume if wearing it is what spends it (item_system re-checks for that tag after the toggle and destroys the item instead of stowing it). The ring of adornment is the only thing in the game that uses it. A row attaches it the way it attaches grants, and saveload reads it back off the row.
Adding a numeric modifier
Rarer, and a bigger change, because a number has to be added somewhere specific – there is no generic place a new number belongs.
-
Add a row to the
modifiers!table ineffects.rs, naming the component and theLoadoutfield it folds into:modifiers! { ... /// Flat modifier on how far the bearer can see. SightBonus => sight_bonus, }That row is the whole declaration. It gives you
pub struct SightBonus(pub i32)implementingModifier, aLoadout.sight_bonusfield, and the line ofLoadout::absorbthat sums it – so the single-pass fold cannot leave one of your modifiers out. The rows are the only place a modifier is named. -
Attach it from rows the way
ArmorBonusis attached – seeRingDef::armor_bonusandinsert_modifier, which skips a zero so an inert row costs nothing. -
Read it where it applies:
equipped_total::<SightBonus>(world, entity) // one number loadout(world, entity).sight_bonus // already taking the foldEither sums the component across everything the entity has equipped plus anything it carries itself. Take the first when a caller wants one number and the second when it is already walking the gear for others – combat and the HUD both want the whole
Loadout, and a sixthequipped_totalcall there would be a sixth walk over the same handful of items.This is the step no framework can do for you:
PowerBonusis read in combat,ThrowBonusin the throw code, and your new one has to be read by whoever owns the number it modifies. A generated field nobody reads is inert, which is a visible nothing rather than a wrong number.
Modifiers are not in EFFECTS; they are saved as the values they are.
An effect nothing can cancel
A wand of cancellation strips every effect except the ones listed in IDENTITY_EFFECTS (models/src/effects.rs). The lurk’s body uses it, and so does every part of a dog. To make your effect one of them, add its stable id to that list. Nothing else changes: revoke_all puts the listed ids back with their ledger entry, so they are saved and still there after a reload.
Use it for what a creature is, not for magic it merely has. A dragon’s FireImmune stays cancellable on purpose, and a cancelled dragon loses it. Make a part uncancellable when taking it away would leave something that is no longer that creature: a dog that can be cancelled into an ordinary helper is not a dog.
Verify
cargo build
cargo test # save round-trips: tests/it/saveload.rs
NIHILURK_SPAWN="<a thing that grants it>" cargo run -p nihilurk
If your effect should survive a reload, the test worth copying is in models/tests/it/wands.rs: the one named cancellation_strips_the_magic_but_leaves_the_creature exercises the grant / probe / revoke path end to end.
Appendix: quick check
- Marker: add a unit struct deriving
Component, Default, Clone, Copyinmodels/src/effects.rs. - Add one
"stable_id" => Typerow toEFFECTS, and never rename the id later. - Hand it out with
.grants(&[Grant::of::<Type>()])on a monster, ring or launcher row. - Read it with a
With<Type>filter,world.get::<Type>(e), orgrant.probe(world, e). - Acts on its own: add one row to
ABILITIESinmodels/src/abilities.rs, with itsMoment. - Fires once on wear: attach
OnWearfrom the row. - Number: add one row to
modifiers!, attach it from rows, and read it withequipped_total. - Nothing may cancel it: add its id to
IDENTITY_EFFECTS. - Run
cargo test, spawn something that grants it, save, reload, and check it is still there. - Fix the docs: one row in the
EFFECTStable in../reference/content-tables.md(update-the-docs.md).
See also
add-a-monster.md .grants(…) on a bestiary row add-an-item.md .grants(…) on a ring or launcher ../reference/content-tables.md the effect registry as a table ../explanation/the-feel-layer.md giving it a look, and the rules for one work-with-the-ecs.md reaching the entity you want to change ../explanation/data-driven-content.md why effects are components ../explanation/ecs-in-nihilurk.md what a component is allowed to be ../explanation/adr-0006-effects-saved-by-id.md why an id, not a position
How to add a new item category
Audience Engine developer, or a content author who has
outgrown the eleven existing categories.
Prerequisites You have read `add-an-item.md` and none of its nine
categories fit. You are comfortable with bevy_ecs
components and with Rust traits.
Result A tenth kind of item that drops on floors, spawns by
name, appears in `-content`, and survives a save.
Do this only when the thing you want is genuinely not one of the ten. A torch is a wand of light with a different flavour; a shield is armour. Reach for a new category when the item carries components no existing Def builds, and when it deserves its own share of the drop table.
Worked example below: food, a thing you eat for hit points.
Step 1: define the row
In models/src/catalog.rs, next to the other Def structs:
/// A ration: eaten once, then gone.
pub struct FoodDef {
pub name: &'static str,
pub color: Color,
/// Hit points restored.
pub nutrition: i32,
}
A row is a description, never behaviour. Keep it to plain data: what it is called, how it draws, and the numbers its components need.
You will also need the component the row attaches, in models/src/components.rs:
/// Eaten for hit points.
#[derive(Component)]
pub struct Food {
pub nutrition: i32,
}
That component is the thing systems will read. The row only puts it on.
Step 2: implement ItemDef
impl ItemDef for FoodDef {
fn name(&self) -> &'static str {
self.name
}
fn spawn(&self, world: &mut World, pos: Position) -> Entity {
world
.spawn((
Name { what: self.name.to_string() },
Renderable { glyph: '%', color: self.color },
pos,
Item,
Food { nutrition: self.nutrition },
Consume,
))
.id()
}
}
spawn builds the item exactly as the row describes it, with no random rolls. That is what tests, spawn_named and NIHILURK_SPAWN all want.
If a floor drop should differ from the plain row – an enchantment, a battery charge, a bundle size – override spawn_as_loot as well:
fn spawn_as_loot(&self, world: &mut World, rng: &mut ChaCha12Rng,
pos: Position) -> Entity {
let e = self.spawn(world, pos);
// roll whatever this category rolls
e
}
Categories with nothing to roll inherit the default, which just calls spawn.
weight() and min_depth() also have defaults (an ordinary weight, and the first floor). Override them only if this category wants per-row rarity – see tune-rarity-and-depth.md.
Step 3: write the table
#[rustfmt::skip]
pub const FOODS: &[FoodDef] = &[
FoodDef { name: "ration", color: Color::DarkYellow, nutrition: 8 },
FoodDef { name: "slime mold", color: Color::Green, nutrition: 3 },
];
#[rustfmt::skip] keeps your column alignment. These tables are read as columns; that is worth more than uniform formatting.
Step 4: give it a share of the drop table
In models/src/spawn.rs, add one line to DROPS:
category!("food", 120, 1, FOODS),
That single line wires up everything: floor drops, spawn_named, the -content listing, and the table tests. The macro erases the table’s element type into three function pointers so the whole thing can stay const; you never write those by hand.
Every other category’s share shrinks in proportion to pay for the new one. No other number needs editing.
Step 5: make it do something
Everything so far is description. If your item needs behaviour, that lives where behaviour lives – item_system in models/src/items.rs dispatches on the component your row attached, handing off to a file under models/src/items/ (potions.rs, scrolls.rs, wands.rs, throwing.rs). Follow how Potion is handled: a component the row put on, read by a system that does not know the catalog exists.
Step 6: teach the save file, if it needs teaching
An item is saved as its row’s id (content) plus a fixed set of component slots in EntitySave (models/src/saveload.rs). Your new Food component is not one of them, so by default a saved ration comes back as a nameless husk that does nothing.
Two ways out, cheapest first:
-
Rebuild it from the row. Extend
restore_from_catalogincatalog.rs, which already does exactly this for weapons, ammo and launchers:if let Some(def) = FOODS.iter().find(|d| d.name == id) { entity.insert((Food { nutrition: def.nutrition }, Consume)); }The row is the definition, so looking it up by id is always cheaper than storing what the row already says. Prefer this.
-
Add a field to
EntitySave. Only when the value differs per entity – a battery’s remaining charges, a stack’s count. Add the field at the end of the struct, read/write it insave_gameandload_game, and bumpSAVE_VERSION.
EntitySavefield order is the save format. Postcard encodes fields positionally. Append; do not insert or reorder. Any change to a saved struct needs a newSAVE_VERSION: the testthe_save_layout_only_changes_with_the_versionfails until it has one, then until you regeneratemodels/tests/golden/save.bin(its message says how). The release is then a minor one, andrelease/bump.luarefuses a patch.
Step 7: identification, if it is equipment
Only if your category is worn gear whose own quality should be a secret at
first – a weapon, suit of armour or launcher with an enchantment plus and a
curse it can roll. That means running it through
crate::catalog::enchant_equipment at spawn time and letting KnownQuality
gate the reveal, same as the three existing equipment categories; copy
whichever is closest.
Every other category – including potions, scrolls, wands and rings – is always shown by its true name. There is no appearance system left to hook into; a ration is a ration, and so is a potion.
Verify
cargo build
cargo run -p nihilurk -- -content | grep -A3 '^food'
NIHILURK_SPAWN="ration" cargo run -p nihilurk
cargo test
The table tests pick your category up automatically: every_drop_category_can_actually_produce_something will fail if you gave it a weight of zero or no rows at its own min_depth, and every_loot_category_can_be_drawn_and_has_rows_to_give fails the same way, and also if it has no rows at the deepest floor.
Round-trip a save before you call it done – step 6 is the one people skip:
cargo test --test it saveload::
Appendix: quick check
- Add the
Defstruct inmodels/src/catalog.rsand the component it attaches inmodels/src/components.rs. - Implement
ItemDef:name,spawn, andspawn_as_loot,weightormin_depthonly if you need them. - Write the table as a
pub constwith#[rustfmt::skip]. - Add one
category!(..)line toDROPSinmodels/src/spawn.rs. - Behaviour: hook the component into
item_systemand a file undermodels/src/items/. - Save: extend
restore_from_catalog, or append a field toEntitySaveand bumpSAVE_VERSION; never insert one. - Worn gear with a hidden quality: run
enchant_equipmentat spawn and letKnownQualitygate it. - Run
cargo run -p nihilurk -- -content | grep -A3 '^<category>'andNIHILURK_SPAWN="<name>" cargo run -p nihilurk. - Run
cargo test, thencargo test --test it saveload::. - Fix the docs:
update-the-docs.md, “A whole item category”.
See also
add-an-item.md the eleven categories that already exist tune-rarity-and-depth.md picking that weight ../reference/spawn-api.md ItemDef, DropCategory, the category! macro ../explanation/data-driven-content.md why rows carry components
How to add an item
Audience Content author.
Prerequisites You know where `models/src/catalog.rs` is. If not,
do `../tutorial/add-your-first-item.md` first.
Result A new item that drops on floors and survives a save.
Equipment (weapons, armour, launchers) hides its
enchantment plus and curse until worn or identified;
every other kind is always shown by its true name.
Twelve categories. Six of them are a single row; the deck has one row and twenty cards. Four also need a name to be identified by, and three of those also need somebody to say what the thing does. Find your category below and follow that recipe only.
At a glance
| Category | Table | Row alone? | Also needs |
|---|---|---|---|
| Weapon | WEAPONS | yes | – |
| Armour | ARMORS | yes | – |
| Coin | COINS | yes | – |
| Ammo | AMMO | yes* | *a launcher effect to answer to |
| Launcher | LAUNCHERS | yes* | *an effect to grant |
| Treat | TREATS | yes | – |
| Ring | RINGS | no | a RingEffect variant |
| Potion | POTIONS | no | a PotionEffect variant + a mechanic |
| Scroll | SCROLLS | no | a ScrollEffect variant + a mechanic |
| Wand | WANDS | no | a WandEffect variant + a mechanic |
| Deck | DECKS | yes | a new card: a CardFace variant, a CARDS row + an arm in items/decks.rs |
| Rune | RUNES | no | a RuneEffect variant + an arm in apply_rune_effect |
Every table is in models/src/catalog.rs. Every effect enum is in models/src/components.rs. Every mechanic is in models/src/items/, one file per kind: potions.rs, scrolls.rs, wands.rs, throwing.rs (models/src/items.rs itself is just the item_system dispatcher).
Row-only categories
Weapon
WeaponDef::new("war hammer", Color::Grey, 9),
new(name, colour, power_die). The die is what the weapon is worth in a swing: damage rolls 1d[power_die]. Draws as ), worn in the hand slot.
Chain .missile(die) if it is built to be thrown – it then rolls that die on impact, is spent on what it hits, and can never be caught out of the air. Chain .piercing() if a throw should run the whole line instead of stopping at the first body.
WeaponDef::new("javelin", Color::DarkYellow, 5).missile(7).piercing(),
Chain .returning() if the throw should strike the first creature and fly home: into the pack, and back into the hand if the weapon was wielded. Leave off .missile() – a projectile is spent on what it hits.
WeaponDef::new("boomerang", Color::DarkYellow, 6).returning(),
Chain .hits(n) after .returning() and one throw strikes n creatures in all, each for full damage: the first in its way, then the nearest other enemy – one the player can see, or for a monster thrower, any in throwing range with a clear line. The moon blade’s 3: hit, hit, hit, home.
WeaponDef::new("moon blade", Color::Cyan, 3).returning().hits(3),
Any weapon can be thrown. .missile() is the difference between a purpose-built missile and a hurled lump of metal.
Chain .reach(n) for a weapon aimed with its own reticle (v) instead of a walk into the target’s tile – a bardiche’s 2, a whip’s 5. Add .reach_piercing() if the strike should run the whole line rather than stopping at the first body, the melee twin of .piercing().
Chain .grants(&[Grant::of::<SomeMarker>()]) for a trick the weapon lends its wielder while it’s in hand – exactly a ring’s own .grants(), and read by the same places a ring’s effects are: an ABILITIES row keyed on Moment::OnHit for something that fires when a blow lands, or a direct world.get::<SomeMarker>(attacker) probe for something checked elsewhere (the battle axe’s Cleaves, checked once from models::melee_attack). This is also how a weapon-only marker stays invisible to combat.rs and engine/ alike – see crate::effects’s module doc.
Chain .on_wear(OnWear(some_fn)) for a one-shot fired the instant it’s wielded – the staff’s “You’re a wizard now!” – the same mechanism a ring of adornment’s flourish uses. .on_doff(OnDoff(some_fn)) is its mirror, fired when the weapon is deliberately put away again (“You’re no longer that magical.”), and it is worth adding only when what the weapon lends is invisible on the HUD: the player has no other way to notice it leaving. It fires from equipment::toggle_equipped alone, never from force_unequip, so dropping it, being disarmed of it and dying with it say nothing.
WeaponDef::new("bardiche", Color::Grey, 7).reach(2).reach_piercing(),
WeaponDef::new("garrote", Color::DarkGrey, 0).grants(&[Grant::of::<VorpalOnCondition>()]),
Armour
ArmorDef { name: "brigandine", color: Color::Grey, armor_die: 6, grants: &[] },
armor_die is what wearing it adds to the defence roll: 1d[armor_die]. Draws as ], worn on the body. The existing nine run 2 (leather) to 9 (plate).
grants lends marker effects while it is worn, the same as a weapon’s .grants(). Spikemail’s &[Grant::of::<Spiked>()] is read by an ABILITIES row at Moment::OnStruck, the moment a blow lands on the bearer.
Coin
CoinDef { name: "electrum coin", color: Color::DarkYellow,
effect: PickupEffect::Coin, amount: 2500, weight: 10 },
Coins are the pickup category: never carried, spent where they lie. A row is name, color, a PickupEffect, and one amount the effect reads (points, hit points, afflictions lifted). Adding a kind of coin means a PickupEffect variant and an arm in items/pickups.rs; adding another coin of an existing kind is one row. Draws as $.
Ammunition
AmmoDef { name: "bolt", color: Color::Grey, die: IMPROVISED_THROW_DIE, venom: false,
launched_die: 5, launched_by: Grant::of::<FireQuarrel>() },
die is what one rolls hurled by hand (ammunition is no better thrown than a mace is: IMPROVISED_THROW_DIE); a wielder carrying the launched_by effect rolls launched_die instead. venom: true saps the victim’s power on every hit that draws blood, as the blowdart does. Stacks up to constants::items::STACK_LIMIT per pack slot, and arrives from the dungeon floor in bundles of constants::loot::AMMO_BUNDLE_MIN..=AMMO_BUNDLE_MAX.
If your ammunition answers to a launcher that does not exist yet, you need a new effect for the pair to meet at – see add-an-effect.md.
Launcher
LauncherDef { name: "sling", color: Color::DarkYellow,
grants: &[Grant::of::<FireStone>()], melee_cap: 1 },
A launcher has no attack die and no armour die. All it does is put an effect on whoever holds it; ammunition that names the same effect is loosed rather than lobbed. Draws as }.
melee_cap is the most it is worth swung at something, whatever the dice or the enchantment say – 1 for both existing launchers. It is the price of the hand: a launcher fills the slot a sword would have, and nihilurk has no wait action to swap back with.
Neither half knows the other exists. That is why a sling is one row here and one row in AMMO.
Treat
TreatDef { name: "bone", color: Color::White, for_item_users: false },
A treat is thrown at a monster as an offer of loyalty and is never used. for_item_users says which monsters it is for: those with ItemUser (true) or those without (false). Stacks like ammo. Draws as %. What accepting one means is models/src/companion.rs.
Rings
A ring is a modifier item, exactly like a sword. Most of them have no behaviour code anywhere – they stack numbers through the same components combat already folds, and lend marker effects through Grants.
-
Append a variant to
RingEffectinmodels/src/components.rs:pub enum RingEffect { ... MaintainArmor, FireResistance, // <- at the end. See the warning below. } -
Add the row in
RINGS:RingDef::new(RingEffect::FireResistance, "ring of fire resistance") .grants(&[Grant::of::<FireImmune>()]),
Available chains:
.power_bonus(n) flat modifier on the wearer's damage roll
.armor_bonus(n) flat modifier on the wearer's armour roll
.throw_bonus(n) flat modifier on everything they throw
.grants(&[...]) marker effects lent while worn
.on_wear(...) a one-shot fired the instant it goes on
A bonus of zero attaches nothing, so an inert ring costs nothing at run time. Giving a ring a body is adding a chain to its existing row.
Reach for .grants(...) before anything else: if the property already exists as a marker some system asks about, you are done. If it does not, add the marker (../how-to/add-an-effect.md) and the one system that reads it – that is how stealth, regeneration, teleportitis and maintain armor were wired, and none of them put a line in catalog.rs beyond the row.
.on_wear(...) is the last resort, for a ring whose effect is an event rather than a property: it takes an OnWear(fn(&mut World, wearer, item)) and fires once, after the ring is worn and identified. The ring of adornment is the only one, and its function lives with the other two ring verbs in models/src/items/rings.rs.
Potions, scrolls and wands
These three are consumables with a mechanic, so they take three edits. The pattern is identical for all three; only the names change.
-
Append a variant to the effect enum in
models/src/components.rs–PotionEffect,ScrollEffectorWandEffect. -
Add the row in
POTIONS,SCROLLSorWANDS.PotionDef { effect: PotionEffect::Levitation, name: "potion of levitation", color: Color::Cyan }, ScrollDef { effect: ScrollEffect::Genocide, name: "scroll of genocide" }, WandDef { effect: WandEffect::Digging, name: "wand of digging", color: Color::DarkYellow, range: 8 },Potions draw as
!, scrolls as?(always white), wands as/. A wand’srangefeeds the aiming reticle; a wand always spawns withconstants::wands::WAND_CHARGES, full. -
Write the mechanic as one arm of the matching function:
apply_potion_effect(world, user, effect) -> bool models/src/items/potions.rs apply_scroll_effect(world, user, effect) models/src/items/scrolls.rs apply_wand_effect(world, user, target, effect) models/src/items/wands.rsA potion’s mechanic returns whether it visibly did anything.
Step 3 is the one the compiler makes you do. All three of those functions are exhaustive matches over their effect enum, the same as
apply_trap_effectis overTrapEffect– no catch-all arm, so step 1’s new variant will not build until step 3 gives it one. The error names the function and the missing variant, so there is no guessing which of the three you forgot.An explicit arm is still allowed to do nothing on purpose – the scroll of blank paper, and the two potions that are only a taste, sit in do-nothing arms today, each variant named rather than caught by a wildcard (see
../explanation/data-driven-content.md, “Where it does not reach”). The guarantee is only that you chose nothing, not that you forgot to write something.
Runes take the same three edits, with RuneEffect, RUNES and apply_rune_effect(world, user, effect) in models/src/items/runes.rs:
RuneDef { effect: RuneEffect::Ice, name: "rune of ice" },
A rune draws as ', always white. It is a scroll that goes inert when read instead of crumbling, and a staircase wakes it, so the mechanic is “what happens on each invocation” and never “what is left afterwards”. RuneEffect::Blank is the one variant with no row: a wand of cancellation makes it. Only the player invokes a rune.
Wands only: if your wand should not open the aiming reticle – it acts on the zapper or the room, like the wand of light – add it to WandEffect::needs_target in models/src/components.rs.
Append enum variants; never insert or reorder them. The save file encodes a variant as its position in the enum. Adding one at the end is safe for existing saves. Putting one in the middle silently turns every saved potion of healing into something else.
Potions, scrolls, wands and rings are always shown by their true name. There is no cosmetic appearance and no per-effect identification to keep in step with the catalog – add a row and it just works. Only equipment (weapons, armour, launchers) hides anything: its enchantment plus and cursed status, until
KnownQualitysays otherwise (see../reference/components.md).
Verify, whichever you added
cargo build
cargo run -p nihilurk -- -content | grep '<your name>'
NIHILURK_SPAWN="<your name>" cargo run -p nihilurk
cargo test --test it content::
The tests check that names are unique, that every row can be built by name, that what spawns keeps its name, and that every drop category can still produce something.
To use the item and see what it does without a terminal of your own, play-through-a-pty.md types the keys for you and prints the screen.
You do not have to register the item, update the loot table, teach the save file about it, or teach combat about it. Why none of that is needed is ../explanation/data-driven-content.md.
Appendix: quick check
- Find your category in “At a glance”.
- Weapon, armour, coin, ammo, launcher, treat: add one row to its table in
models/src/catalog.rs. - Ring: append a
RingEffectvariant at the end, then add theRingDef::newrow. - Potion, scroll, wand, rune: append the effect variant at the end, add the row, write the arm in
apply_potion_effect,apply_scroll_effect,apply_wand_effectorapply_rune_effect. - A wand with no reticle (it acts on the room or the zapper): add it to
WandEffect::needs_target. - Never insert or reorder an enum variant; a save stores its position.
- Run
cargo run -p nihilurk -- -content | grep '<name>'. - Run
NIHILURK_SPAWN="<name>" cargo run -p nihilurk, thencargo test --test it content::. - Fix the docs: the enum line in
../reference/components.md, and for a wand its table row (update-the-docs.md).
See also
add-an-effect.md a property no component covers yet add-an-item-category.md a whole new kind of item tune-rarity-and-depth.md make it rare, or make it deep ../reference/content-tables.md every field of every table ../explanation/data-driven-content.md why rows carry components
How to cut a release
Audience The maintainer.
Prerequisites A clean `master` with green CI, and crates.io
Trusted Publishing set up once for each of the
four crates (see "Before the first automated
release").
Result A new version on GitHub, with archives for two
Linux machines, Windows and Apple Silicon Macs,
and on crates.io, as four crates.
One command and one push.
A release is release/bump.lua and a tag. The script edits the files, runs the checks and tags the commit. CI does the publishing when the tag is pushed. The push is the one step that cannot be taken back, and the script never does it for you.
Quick commands
lua release/bump.lua patch bump, test, commit, tag
git push origin master vX.Y.Z starts the release
gh run watch watch CI
Use patch, minor, major or an exact X.Y.Z. Two flags:
--dry-run show every line that would change, and change nothing
--no-cargo skip the cargo steps, if you have just run them yourself
The script prints the exact push command when it finishes, with the version filled in.
Which number to bump
The number says what an upgrade costs a player:
patch 0.1.3 to 0.1.4 nothing. Saves and everything else carry over.
minor 0.1.3 to 0.2.0 old saves stop loading.
major 0.2.0 to 1.0.0 everything from the old version is invalid.
So a release that breaks saves is a minor bump, and a release that breaks everything the old version made is a major one.
The script checks one cause. If SAVE_VERSION in models/src/saveload.rs changed since the last tag, it refuses any new version that keeps the same major and minor, so a save break cannot ship as a patch. A changed save layout cannot reach that point without a new SAVE_VERSION: the_save_layout_only_changes_with_the_version fails until it has one (add-an-item-category.md). The other usual cause is a renamed content row, because a save stores row ids (add-a-monster.md). Nothing catches that, so read the diff since the last tag for renames.
What the script does
It refuses, and edits nothing, if the working tree has uncommitted changes, if you are not on master, if the tag already exists, if the new version is not greater than the current one, or if SAVE_VERSION changed since the last tag and the new version keeps the same major and minor. Then it edits:
the four published crates `version`, and every `path` pin on
a dependency line
doc/nihilurk.6 the version and date in the `.TH` line
aur/PKGBUILD `pkgver`, and `pkgrel` back to 1
README.md the four download links and the
version in the line above them
All four crates move to the same number, so there is one version to think about. engine/tests/workspace.rs fails if one crate or one pin falls behind. compat/ is a test rig and is left alone.
Then, unless --no-cargo: cargo update --workspace refreshes Cargo.lock (it touches only the four workspace lines, no dependency moves), cargo test --locked runs the suite, and cargo publish --workspace --dry-run proves all four packages build. If a step fails, the edits stay in the working tree and git checkout . puts them back. When they pass, it commits Release vX.Y.Z and tags it.
release/bump_test.lua runs the script in a throwaway repository. Run it with lua release/bump_test.lua after you change the script.
What CI does
Pushing the tag starts .github/workflows/release.yml:
check The tag has to be v plus the version in
engine/Cargo.toml. Then cargo test.
build One runner per machine. Builds, unpacks and plays
each tarball.
publish Creates the GitHub release with the tarballs.
crates-io Publishes the four crates, in dependency order. It
skips any version crates.io already has, so a
failed run can be run again.
publish and crates-io run in parallel once build is green. A failure in check or build stops both, and nothing reaches GitHub or crates.io.
crates-io uses Trusted Publishing: the job trades GitHub’s identity token for a short-lived publish token. No secret is stored in the repository.
Before the first automated release
Do this once. It is the one part CI cannot do for you. For each of nihilurk, nihilurk-models, nihilurk-strings and nihilurk-particle-core, open the crate’s settings on crates.io and add a trusted publisher with:
GitHub user bae-alves
Repository nihilurk
Workflow filename release.yml
Environment (leave empty)
A crate has to exist before it can have a trusted publisher. All four do.
To check the setup without releasing anything, rehearse it:
gh workflow run release.yml --ref master
gh run watch
The rehearsal job asks crates.io for a publish token and discards it. A pass proves crates.io accepts this repository and release.yml. It does not prove all four crates are configured, because crates.io does not report which crates the token covers: look at each crate’s settings once. Nothing is built or published, and the jobs that need a tag skip themselves.
When it goes wrong
The script refuses Read the message. It names the
cause and changed nothing.
A cargo step in the script fails Fix the cause, then
git checkout . and run it again.
check or build fails Nothing was published. Fix it,
delete the tag locally and on
GitHub, and run the script again:
git tag -d vX.Y.Z
git push origin :refs/tags/vX.Y.Z
crates-io fails part way Re-run the failed job from the
Actions tab. It skips what is
already published.
The tag is on GitHub, no run starts Delete the remote tag and push it
again at the same commit.
A bad version is live cargo yank --version X.Y.Z nihilurk for each crate. A
yank stops new projects from
picking it up. It does not
delete it.
The manual versions of these steps are in publish-to-crates-io.md and publish-a-github-release.md, for the day CI cannot publish.
Adding another place to publish
Each place is one job in release.yml. Give it needs: build (or needs: publish if it uses the GitHub release), keep its credentials in repository secrets, and gate it on a repository variable such as vars.AUR_ENABLED == 'true' until the account behind it exists. A new job does not change the script, because the script only edits files and tags.
The AUR package is not published by CI yet. The script bumps pkgver, and the repo’s PKGBUILD carries SKIP for the sha. Until an AUR job exists, publishing there is a hand step after the tag: see “Updating the AUR package” in publish-a-github-release.md.
See also
../../release/bump.lua the script
../../release/bump_test.lua its test
../../.github/workflows/release.yml what the tag starts
publish-to-crates-io.md the manual path to crates.io
publish-a-github-release.md the manual path to a GitHub release
How to play the game from a script
Audience Anyone who changed the game and wants to see it work
in the real terminal, with no terminal in front of
them: a content author, an engine developer, or
Claude Code.
Prerequisites A Linux box with `script` (util-linux), `lua` and a
Rust toolchain. `spawn-a-thing.md` says what
`NIHILURK_SPAWN` does.
Result The screen the game showed after your keys, and the
cells that sit on a coloured background, printed as
text.
Tests say a rule holds. They cannot say how it looks, and nobody tests that on purpose (../explanation/adr-0004-no-tests-of-presentation.md). The way to check a look is to play. .claude/skills/play-nihilurk/play.sh plays for you: it starts the game in a pty, types the keys you list, waits, and prints the screen. It builds the game first. The game runs from a scratch directory with -ns -nobones -nshake -nb, so it writes no save or bones. The scratch directory is also its data directory, so it never reads your keymap or writes to your leaderboard and morgue.
.claude/skills/play-nihilurk/play.sh -S "rune of ice,aquator" ' ' @1 '?'
The keys
Each argument is one key, typed as it stands. printf %b runs on it, so '\r', '\e' and '\t' are Enter, Escape and Tab. Four words are not keys:
? print the screen as it is now
% list the cells on a coloured background
@SECS wait that long before the next key
#TEXT print a note with the screens; sends nothing
The screen prints once more when the keys run out. A key waits -d seconds (0.4 by default) before the next one goes in. The other options are -s SEED, -S LIST (that is NIHILURK_SPAWN), -a "FLAGS" for the game (-a "-b lurk"), -w SECS for the first frame, -z RxC for the terminal size (25x80 by default, the smallest the game draws cleanly), and -k FILE to keep the raw bytes.
A walk-through: read a rune next to an aquator
A seed fixes the floor, so look first, plan the walk from what you see, then replay it.
play.sh -S "rune of ice,aquator" ' ' @1 '?'
The first frame has a --MORE-- that needs a Space. The map shows the rune ' up and to the left of you and the aquator A right of it. Step onto the rune (items are picked up as you walk over them), read it with r and the pack letter, and look:
play.sh -S "rune of ice,aquator" ' ' @1 y @2 ' ' r f @4 '#after the nova' '?' '%'
f is the first spawned item because nihil starts with a to e. A lurk starts empty, so there it is a; press i to be sure.
Reading colour
The screen is text. Colour is how the game marks a monster that cannot fight at full strength, and % prints it as ROW COL GLYPH COLOUR in the names crossterm uses:
== backgrounds ==
4 19 U Cyan
That line is an ur-vile on a cyan square: paralysed. The tints and what each one means are in ../reference/rendering.md. Run the same keys without the step that should cause the tint, and the list is empty: that is your control.
What wastes a run
--MORE--eats keys. Send a Space after anything that logs several messages, and wait (@2to@4) after anything animated. The log paints letter by letter, so an early screen shows half a sentence. Read the effect off HP, the badge row, the pack, the map and%, and use the log as a hint.oexplores and picks things up, and any key pressed during it stops it. Put@2after it. It refuses while a hostile is in view.- You cannot pass a turn. To give monsters time, walk away from them.
- The first step can kill. A dragon one-shots nihil, and a lurk has 6 HP. An orc takes most of nihil’s 12 HP. To test an effect on a monster, spawn an aquator: awake, hostile, 9 HP, weak hits. Use a troll or a quagga when the test needs more. A yeti is immune to cold, and a red demon and a gnome are peaceful, so a spell that needs a hostile target ignores them.
- Aiming:
z, the wand’s letter, direction keys to move the cursor,'\r'to fire,xto cancel.
Hold a game open
play.sh replays its keys from the start every time. To play one run a key at a time, start it once with agent/serve.sh and send keys with agent/act.sh:
a=.claude/skills/play-nihilurk/agent
d=$(mktemp -d)
NIHILURK_BIN="$PWD/target/debug/nihilurk" NIHILURK_SPAWN=aquator $a/serve.sh start "$d" -s 1
$a/act.sh -d "$d" ' ' l # one call per step; the run waits in between
kill "$(cat "$d/pid")"
NIHILURK_BIN is the binary to run, as an absolute path. Without it, start runs the nihilurk on PATH. Game flags go after the directory.
To hand the run to a player outside the machine, serve it from a container on a TCP port instead:
cargo build -p nihilurk --target x86_64-unknown-linux-musl
docker build -t nihilurk-agent -f $a/Dockerfile .claude/skills/play-nihilurk
docker run --rm -d -p 127.0.0.1:7777:7777 \
--read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 \
-v "$PWD/target/x86_64-unknown-linux-musl/debug/nihilurk:/usr/local/bin/nihilurk:ro" \
nihilurk-agent -s 1
.claude/skills/play-nihilurk/agent/act.sh ' ' l l
The image holds only the server. The game is a static binary built on the host and mounted in, so it never compiles inside Docker. Arguments after the image name are game flags, and -e NIHILURK_SPAWN=... passes through. act.sh without -d connects to 127.0.0.1:7777. Either way, the server, serve.sh, runs the game in a pty with -ns -nobones -nshake -nb -anim-rate 0.1, with the run’s directory as its data directory. Each line a client sends is one key, in the notation above. Once the game signals that it waits on a key, the server answers with a percept: the 25 screen rows, a ROW COL GLYPH COLOUR line for each tinted cell, then end. Once the game has exited it answers over, and keeps answering every new connection with the last percept and over, until docker stop in the container. Sessions take turns, in the container and on the host: one that arrives mid-session waits for the other to hang up. A run that no session has touched for NIHILURK_IDLE seconds (default 1800, 0 never) is killed and answers over from then on, and a session that sends nothing for that long is hung up on. A line longer than 4096 bytes is cut into keys of that size. A client gets a percept on connect and one per line after that, so it acts only on what it has seen. It can hang up at any point, and the run waits for the next connection. act.sh connects through bash’s /dev/tcp, so any line-based client works, nc included.
The protocol has no login: whoever reaches the port plays the run. Publish the port on 127.0.0.1, as above, unless the player is on a network you trust. The second docker run line keeps the container read-only, with no capabilities and a cap on processes, and the server inside runs as an unprivileged user. serve.sh listen binds 127.0.0.1 unless NIHILURK_BIND says otherwise; the image sets it to 0.0.0.0 so Docker can map the port.
The server sets NIHILURK_READY for the game, which then writes a signal each time it blocks for a key (../reference/cli-and-env.md). Auto-explore, a paralysed turn and particles never signal, so their output reaches a percept only after they have finished, however long they take. A game that never signals, such as a build from before the signal, gets a percept after 250 ms without output instead. The signal changes nothing on screen, so a seed and the same keys give the same percepts.
Its own tests
bash .claude/skills/play-nihilurk/play_test.sh
lua .claude/skills/play-nihilurk/screen_test.lua
bash .claude/skills/play-nihilurk/agent/serve_test.sh
play_test.sh runs the driver against cat, printf and sleep, so it needs no build. screen_test.lua checks the screen reader. agent/serve_test.sh runs the socket server over stdio against stand-ins, so it needs no Docker and no socat. None of them runs in CI or in the pre-commit hook.
See also
spawn-a-thing.md put the thing in front of you
add-an-item.md the recipe whose last step is “look at it”
../reference/rendering.md the status tints % reports
../reference/cli-and-env.md the flags and NIHILURK_SPAWN
../explanation/adr-0004-no-tests-of-presentation.md why a look is checked by playing
How to publish a GitHub release
Audience The maintainer, cutting a release.
Prerequisites Push access to the repository. The version bumped
and committed, as in
`publish-to-crates-io.md`.
Result A GitHub release with one tarball per machine, each
with a checksum, built and checked by CI.
This is the manual path. release/bump.lua does the bump and the tag for you: see cut-a-release.md.
Pushing a tag is the whole procedure. .github/workflows/release.yml does the rest, and it cannot be undone from your side once the release is public, so check the version before you tag.
Quick commands
cargo test green before you tag
git tag vX.Y.Z the number in engine/Cargo.toml
git push origin vX.Y.Z starts the workflow
Watch it with gh run watch. When the publish job finishes, the release is at https://github.com/bae-alves/nihilurk/releases.
To try the packaging on your own machine before you tag, without CI:
release/test_package.sh x86_64-unknown-linux-musl
It builds all four languages with fat LTO, so it takes a few minutes, then unpacks the tarball and runs it.
What the workflow does
Four jobs. check runs first, then build, then publish and crates-io side by side. Any failure stops the ones after it.
check Fails if the tag is not v plus the version in
engine/Cargo.toml, then runs cargo test.
build One runner per target. Runs release/test_package.sh, which
calls release/package.sh and then plays with the result.
Uploads the tarball and its .sha256.
publish Collects every tarball and creates the release with
generated notes.
crates-io Publishes the four crates to crates.io, skipping any
version already there. See cut-a-release.md.
The targets:
x86_64-unknown-linux-musl ubuntu-24.04
aarch64-unknown-linux-musl ubuntu-24.04-arm
x86_64-pc-windows-msvc windows-latest
aarch64-apple-darwin macos-latest
The runner images are named, not ubuntu-latest. GitHub moves that label to Ubuntu 26 on 2026-10-19, which would change the musl-tools the build installs without a commit in this repository. Move to a newer image on purpose, and run release/test_package.sh first.
The workflows use actions/*@v4. They run on a forced Node 24 and print a deprecation note. They are not bumped, because only a tag exercises the release workflow, and a bump there would go untested until the release it breaks.
Windows gets a .zip of .exe files and the other targets a .tar.gz. release/package.sh and release/test_package.sh run in bash on all three systems, and the workflow names shell: bash because Windows would otherwise run a .sh file in PowerShell and report success. Windows has no LANG, so the dispatcher starts English there; nihilurk --lang pt picks another language. A macOS download from a browser is quarantined by Gatekeeper, since the binaries are not signed: xattr -d com.apple.quarantine nihilurk* clears it.
The Linux builds are musl, so each is one static file with no glibc to match. ../explanation/cross-platform-testing.md says why that is a promise and not a preference.
What is in a tarball
nihilurk-<version>-<target>/
nihilurk the dispatcher; picks a language from $LANG
nihilurk-en the English game
nihilurk-pt Portuguese (beta)
nihilurk-es Spanish (beta)
nihilurk-ht Haitian Creole (beta)
nihilurk.6 the man page
MANUAL.md
LICENSE
The five binaries must stay in one directory. The dispatcher looks for nihilurk-<lang> next to itself and exits if it is not there. A user can put the directory anywhere, or install the files into /usr/local/bin.
What CI shows
Every tarball is unpacked and run by CI on the machine that built it: the dispatcher has to start all four language binaries, and the four have to differ. That proves each binary launches and that the packaging is right. It is not a playtest.
Adding a target is one line in the matrix of release.yml. Windows was played by a person before it went in. The macOS build has not been played yet: CI is all it has.
Updating the AUR package
aur/PKGBUILD is bumped with everything else. release/bump.lua sets pkgver in the release commit, so the PKGBUILD names the tag that commit gets, and aur_check.sh builds that tag with the PKGBUILD as written.
The repo copy carries sha256sums=('SKIP'). A real sha exists only once the tag does, and it would be stale from then on. The copy that goes to the AUR needs the real one, and aur_check.sh does not compare it.
CI does not publish to the AUR yet, so until it does that is a hand step after the tag exists. Work on a copy, and never commit the result:
cp -r aur /tmp/aur-publish && cd /tmp/aur-publish
updpkgsums writes the real sha256sums
makepkg --printsrcinfo > .SRCINFO
then push PKGBUILD and .SRCINFO to the AUR repository. Run ./aur_check.sh in the repository first: both trees must be green.
When it goes wrong
check says the tag disagrees Delete the tag, bump the version,
commit, tag again:
git tag -d vX.Y.Z
git push origin :refs/tags/vX.Y.Z
One build job fails Read its log. The same command runs
on your machine as
release/test_package.sh <target>.
Fix, delete the tag, tag again.
publish fails after the builds pass Re-run the failed job from the
Actions tab. The artifacts are kept
for the run.
The tag is on GitHub, no run starts Happened on 2026-10-01, when the
tag went up in the same push that
first brought release.yml to
GitHub. The cause is not confirmed.
gh run list shows nothing. Delete
the remote tag and push it again at
the same commit:
git push origin :refs/tags/vX.Y.Z
git push origin vX.Y.Z
A bad release is live gh release delete vX.Y.Z --yes,
then git push origin :refs/tags/vX.Y.Z. Anyone who
already downloaded it keeps it.
See also
publish-to-crates-io.md the other half of a release
../../aur_check.sh builds the PKGBUILD's pinned tag and this tree
../explanation/cross-platform-testing.md why the Linux builds are musl
../../release/package.sh the build, one target
../../release/test_package.sh the check on that build
../../.github/workflows/release.yml the workflow itself
How to publish the documentation site
Audience The maintainer, and anyone who changes how the site
looks or what it lists.
Prerequisites mdBook 0.5.4 (`cargo install mdbook --locked
--version 0.5.4`) to build it on your machine, and
Lua. CI needs neither from you.
Result `MANUAL.md` and `docs/` on GitHub Pages as a site a
search engine or an AI crawler can read, with a
sitemap, `robots.txt` and `llms.txt`.
The site is not a second copy of the docs. site/build.lua stages the pages from where they live, and mdBook turns them into HTML. Nothing it makes is committed.
Quick commands
lua site/build.lua stage the pages in target/site-book/
mdbook build target/site-book build the site into target/site-book/book
mdbook serve target/site-book read it at localhost:3000
lua site/build_test.lua the script's test
Pushing to master runs .github/workflows/pages.yml when README.md, MANUAL.md, docs/, site/ or the workflow changes.
What the script does
It copies README.md, MANUAL.md and every page under docs/ into target/site-book/src/, keeping their paths. Then:
links a backticked path that names a page, such as
`../reference/cli-and-env.md`, becomes a link. A path
that names nothing stays as written. Code blocks are
left alone.
SUMMARY.md the contents list: the landing page, the manual, then
tutorials, how-to guides, reference, explanation.
Each entry takes its title from the page's first heading.
sitemap.xml one line per page.
robots.txt allows everything and points at the sitemap.
llms.txt `site/llms.txt`, for AI answer engines.
It refuses a page with no heading, and names it.
The site URL is site-url in site/book.toml, and nowhere else. The script writes it into the sitemap, robots.txt, llms.txt and the structured data in site/theme/head.hbs. A custom domain is a one-line change there.
The facts a crawler reads
site/llms.txt states what nihilurk is, the install line, the languages and the license. Each of those is a fact that README.md or engine/Cargo.toml also holds. When you change one, change both: nothing checks it.
The description in site/book.toml is the one every page shares. mdBook has no description per page.
Before the first deploy
Do this once. In the repository’s Settings, under Pages, set Source to “GitHub Actions”. Then run the workflow from the Actions tab, or push a change under docs/. Check https://bae-alves.github.io/nihilurk/sitemap.xml.
Then tell the search engines. Submit that sitemap in Google Search Console and Bing Webmaster Tools. Both need your account, so no script does it.
When it goes wrong
The script names a page That page has no heading. Add one.
A build warning about Prose holds a bare `<word>`, which HTML
an unclosed HTML tag reads as a tag and hides. Put it in backticks.
The workflow says the Source is not set to "GitHub Actions" yet.
site is not enabled
mdBook moved on The version and its checksum are two
lines in `pages.yml`. Change both, build
it here first, and run `sha256sum` on the
tarball.
See also
../../site/build.lua the script
../../site/build_test.lua its test
../../.github/workflows/pages.yml what a push to master starts
update-the-docs.md which pages a change touches
../explanation/documentation-style.md the house style the pages keep
How to publish nihilurk to crates.io
Audience The maintainer, cutting a release.
Prerequisites A crates.io account and an API token
(`cargo login`). A clean working tree, and the
release committed and tagged. A verified email on
the account: crates.io refuses an upload without one.
Result Four crates on crates.io, and `cargo install
nihilurk` gives a stranger the game.
This is the manual path. A release is lua release/bump.lua and a tag push, and CI publishes: see cut-a-release.md. Use this page when CI cannot.
Publishing is the one step in this repository that cannot be taken back. A version, once uploaded, can be yanked but never deleted, and a crate name is held for good. Everything before the last command is a dry run. Do those first, every time.
Quick commands
cargo test workspace still green
cargo publish --workspace --dry-run package and build all four
cargo publish --workspace the real upload, in order
--workspace sends the crates in dependency order. compat is skipped on its own, because it is publish = false.
crates.io limits how fast a new crate can appear: a burst of about five, then one every ten minutes. A first upload of all four fits in the burst. A fifth new crate would wait.
What goes out
Four crates, in the order cargo sends them:
nihilurk-particle-core particle-core/ the `no_std` arithmetic
nihilurk-strings strings/ every sentence, per language
nihilurk-models models/ the game
nihilurk engine/ the binary
The directories keep their short names. Only the package names carry the game’s name, and [lib] name keeps the short library names, so use models::*; still compiles. ../explanation/adr-0002-two-names-per-crate.md has the reasoning.
compat/ does not go out. It pulls in ratatui, which the shipped binary may not depend on, and nobody installs a test rig.
Before the first upload
-
Pick a version no tag has used. Bump every crate that changed, and the
version = "..."on eachpathdependency that points at it:engine/Cargo.toml version, and models = { ..., version = "X.Y.Z", ... } models/Cargo.toml versionengine/tests/workspace.rsfails if apathdependency has no version at all. It cannot tell you the version is stale, and cargo cannot either until the upload has half happened. -
Check the page crates.io will show. Description, keywords, categories,
readmeandrust-versionare frozen into each version, so a wrong one costs a new number.engine/tests/workspace.rsholds the limits crates.io enforces (five keywords, twenty characters each), and fails on an integration test that reads outside its crate without anexcludeline, because that test cannot pass from an unpacked download. The floors differ by crate:nihilurk-particle-coreandnihilurk-stringsbuild on 1.85,nihilurk-modelsandnihilurkneed 1.91 (let chains and a constTypeId::of; 1.90 fails). The two small crates stay on 1.85 because edition 2024 sets that floor and they need nothing newer. Raise one if you add code that needs a newer compiler, and build it on that compiler first (cargo +1.85.0 check -p nihilurk-strings). -
Commit, then tag:
git tag v<version>. The GitHub release and the AUR package both build from that tag. Pushing the tag starts the release workflow, so seepublish-a-github-release.mdfirst. -
Run the three commands under Quick commands, in order. A dry run that says
aborting upload due to dry runfor all four crates is a pass.
After the upload
A stranger installs the English game with:
cargo install nihilurk
and another language by naming its feature:
cargo install nihilurk --no-default-features --features lang-pt
That installs one binary, nihilurk, in one language, into ~/.cargo/bin. A stranger whose shell lacks that directory on PATH gets “command not found”, so the README’s install steps carries the fix; keep it there when you edit the install lines.
The forwarder that picks a language from $LANG (nihilurk-dispatch) is behind the dispatch feature and is not installed, because it execs a nihilurk-<lang> next to itself and an install from crates.io has none. release/package.sh and aur/PKGBUILD ask for it by name. --no-default-features is not optional for a second language: leaving it off builds English and the requested language together, and strings then fails to compile.
Check the install once, in a scratch directory, before you announce anything:
cargo install nihilurk --root /tmp/nih-check
/tmp/nih-check/bin/nihilurk -content | sed -n 1,8p
When it goes wrong
The upload stops after two crates Fix the cause and bump the
versions of the crates not yet
sent. Send each with
cargo publish -p <crate>, in the
order under “What goes out”. I have
not tested a partial re-run of
--workspace, so do not rely on it.
A bad version is live cargo yank --version <v> <crate>.
It stops new projects from picking
the version up. It does not delete
it, and it does not undo an
install.
See also
publish-a-github-release.md the other half of a release
../explanation/adr-0002-two-names-per-crate.md why the names differ from the directories
../reference/cli-and-env.md the flags of the installed binary
../../engine/tests/workspace.rs the tests that guard the manifests
../../aur/PKGBUILD the other way a release gets installed
How to run the compatibility pipeline
Audience Anyone who wants to know whether nihilurk still runs on
a Pi, a 32-bit netbook, or a Graviton instance --
and anyone who just made the binary bigger.
Prerequisites Docker, and `cargo install cross`. QEMU hooks for
the ARM rows; the script installs them for you.
Everything else is optional and is skipped with a
note where it is missing.
Result One line per machine saying whether nihilurk runs
there, the size of the binary for each, and which
crate is responsible for those bytes.
One script:
./compat_test.sh
Read no further if it comes back green.
Quick commands
./compat_test.sh everything (~10 min cold)
./compat_test.sh --targets cloud one machine
./compat_test.sh --no-build reuse what is already built (needs --keep on the run before)
./compat_test.sh --keep keep the images and target/cross afterwards
./compat_test.sh --no-headless skip the no-terminal check (models has no crossterm)
./compat_test.sh --timeout 60 per-row seconds for the run check
./compat_test.sh --help
When a run ends, pass or fail, it frees the disk it took: the docker images
it pulled (about 22 GB) and target/cross (about 6 GB). It removes only the
images the pipeline names, never prunes, and keeps any image a container is
using. So every run is a cold run; use --keep while you iterate.
The three phases on their own:
./compat/cross_build.sh build for every machine + blame
./compat/run_check.sh check each machine actually runs it
The report:
target/release/nihilurk-compat the matrix, as text
target/release/nihilurk-compat report same thing, explicit
target/release/nihilurk-compat gate one line, and an exit code
Reports and logs land in target/compat/. The binaries themselves land in target/cross/<triple>/<triple>/release/.
Each run replaces target/compat/results.tsv rather than adding to it, --targets included. So a one-row run reports one row, and the machines it skipped show -. That is deliberate: a report mixing rows checked against two different builds of the game, with nothing on the page saying which was which, is worse than a report with gaps in it.
The machines
compat/matrix.tsv is the list, and it is the only list. One row per machine, tab-separated; adding a machine is adding a line.
cloud x86_64 musl native t3.micro, and the control
graviton aarch64 musl qemu AWS Graviton, an M-series VM
pi-zero armv7 musl qemu Raspberry Pi Zero 2 W
pi2 armv7 musl qemu Raspberry Pi 2 B
pi3 aarch64 musl qemu Raspberry Pi 3 B
pi4 aarch64 musl qemu Raspberry Pi 4 B
cm aarch64 musl qemu Compute Module 4 (1GB Lite)
potato i686 musl native 32-bit netbook, ca. 2008
There is no CPU or memory cap on any row. nihilurk has no workload heavy enough to need a stress test – every feel-layer effect is bounded – so the matrix checks a narrower, cheaper claim instead: if it builds, the image has a shell, and the target is std (every linux row here is), nihilurk can run there. cloud and potato run on the host CPU directly; every other linux row runs under a directly-invoked qemu-user-static interpreter, fetched by the pipeline on its own – no binfmt_misc, no --privileged.
Every linux row’s image is checked for a shell before the binary is run – that is not optional, nihilurk cannot start without a real terminal under it – and a row that fails the check fails the pipeline rather than being silently skipped. See “why every linux row must have a shell” in ../explanation/cross-platform-testing.md.
- Does nihilurk still run everywhere?
-
Run the pipeline:
./compat_test.sh -
Read the table. One line per machine:
machine target exec binary verdict cloud x86_64-unknown-linux-musl native 3.5 MiB runs graviton aarch64-unknown-linux-musl qemu 3.1 MiB runs pi-zero armv7-unknown-linux-musleabihf qemu 3.2 MiB runs potato i686-unknown-linux-musl native 3.2 MiB runsThe verdict column is the whole story:
runs the image has a shell and the binary started cleanly no shell the image has no usable shell -- nihilurk cannot run without one does not run had a shell, but the binary did not start - not attempted (excluded by --targets, or never built) -
runsexits 0.no shellanddoes not runfail the pipeline, because they mean a machine that is supposed to run nihilurk cannot. -
What is being checked
nihilurk -content: it prints the game’s content index and exits, without ever touching the terminal’s alternate screen. That is the whole check – if it prints something and exits 0, the row can run nihilurk. There is nothing to grade beyond that: nihilurk asks nothing of a machine that cross-compiling a full Rust toolchain (which cross_build.sh already proved this target can do) does not ask for first.
- How big is it, and whose fault is that?
Phase 1 prints it per target, for the binary that actually ships – --release, fat LTO, one codegen unit, symbols stripped:
target triple game bytes
cloud x86_64-unknown-linux-musl 1.8 MiB 1934384
potato i686-unknown-linux-musl 1.8 MiB 1846016
The exact byte count is there because this is a table you diff against the last run, and the 32-bit rows come out about 4% smaller – which rounds to the same “1.8 MiB” and would otherwise be invisible.
Do not read too much into the last few kilobytes. A fat-LTO build is not reproducible to the byte, and the same source rebuilt can move by a page either way. A crate’s share moving, or a target gaining tens of kilobytes, is the signal.
Then it attributes those bytes per crate:
crate bytes share
models 477599 28.7%
bevy_ecs 343492 20.7%
engine 210844 12.7%
core 159366 9.6%
std 98965 6.0%
libc + compiler builtins 65823 4.0%
total in symbols 1662671
Those are symbol bytes, not file bytes – the difference is section headers, padding, relocations and the constant pool – so the total comes in under the size printed above it. Read the shares.
The full table per machine is in target/compat/blame-<machine>.txt. --no-blame skips the stage; --top 20 lists more crates.
- Adding a machine
Add a line to compat/matrix.tsv. Seven tab-separated fields:
id pi5
target aarch64-unknown-linux-musl
class linux has an OS and a shell; nihilurk runs here
platform linux/arm64 docker --platform for a native row; for a
qemu row, only picks the interpreter --
see run_check.sh
image alpine:3.22 what the binary is executed in -- checked
for a shell before it is trusted, see
"the machines" above
exec qemu native | qemu | none
note Raspberry Pi 5, 64-bit OS
Then, if it is a new triple, add its cross image to compat/Cross.toml:
[target.aarch64-unknown-linux-musl]
image = "ghcr.io/cross-rs/aarch64-unknown-linux-musl:0.2.5"
That is all. cross_build.sh, run_check.sh and the report all read the table.
Two rules the tests enforce, so you will hear about it:
-
Every
linuxrow must be a-musltriple. A-gnubinary is bound to the glibc it was linked against, which is the one thing the matrix exists to rule out.cargo test -p nihilurk-compat
Troubleshooting
A qemu row skipped saying it could not fetch its interpreter
`run_check.sh` pulls the static qemu-user interpreter it needs
from `tonistiigi/binfmt` the first time a qemu row runs, and
caches it in `target/compat/qemu/`. If that pull fails -- no
network, registry unreachable -- the row is skipped rather than
run against the wrong thing. Check docker can reach the registry:
docker pull tonistiigi/binfmt
No `--privileged`, no binfmt_misc, and nothing written outside
this pipeline's own output directory.
docker is not answering
sudo systemctl start docker
sudo usermod -aG docker $USER # then log out and back in
cross not found
cargo install cross
The host's own musl triple will still build with plain cargo:
cargo build --release --target x86_64-unknown-linux-musl -p nihilurk
no binary at target/cross/<triple>/<triple>/release/nihilurk
Phase 2 was run before phase 1, or for a row phase 1 skipped:
./compat/cross_build.sh --targets <machine>
The triple appears twice because each target gets its own cargo
target directory. That is deliberate and it fixes a real failure --
see "Why each target gets its own target directory" in the
explanation page.
<machine> is dynamically linked
The triple is not a `-musl` one. Fix the row; a dynamically linked
binary will not start on a machine with a different libc.
A build dies on weak version GLIBC_2.xx not found
A host artifact leaked into a cross container, or one cross image's
artifact into another's. Each target has its own target directory
precisely to stop that, so this means something was built with
`CARGO_TARGET_DIR` unset or pointing elsewhere:
rm -rf target/cross && ./compat/cross_build.sh
A row says does not run and the log ends abruptly
The container exited non-zero or the timeout killed it. The report
points at `target/compat/run-<machine>.log`.
The report contradicts what the pipeline just printed live
The matrix loop and `nihilurk-compat report` disagree -- a row the loop
just logged `ok runs: ...` for shows up as `does not run`, or is
missing from the table entirely. `matrix.tsv` is compiled into
`nihilurk-compat` with `include_str!`, so a `target/release/nihilurk-compat`
built before the last change to it, or to `compat/src/*.rs`, is
reading fresh results with stale code. Both scripts build
`nihilurk-compat` unconditionally rather than only when the binary is
missing, specifically because "it already exists" and "it matches
the current source" are different questions and only `cargo build`
can answer the second one. If you still see this on an older
checkout:
cargo build --release -p nihilurk-compat
llvm-nm cannot read a <triple> binary
GNU `nm` often refuses a foreign ELF; llvm-nm reads all of them.
rustup component add llvm-tools
The blame table says “no sized symbols”
It read the stripped binary. The `profiling` build is what carries
the symbols; check the build log for why it did not produce one.
See also
../explanation/cross-platform-testing.md why it is built this way
../../compat/matrix.tsv the machine list itself
Every script takes --help, and each one’s help is the authority on its own flags:
./compat_test.sh --help
./compat/cross_build.sh --help
./compat/run_check.sh --help
target/release/nihilurk-compat --help
How to spawn a thing
Audience Engine developer, or a content author who wants to
look at the row they just wrote.
Prerequisites Rust and a passing familiarity with bevy_ecs.
`../reference/spawn-api.md` lists the signatures;
this page is the recipes.
Result A named monster, item, trap or the relic, in the
world, on the tile you meant.
One function covers nearly every case:
spawn_named(&mut world, "dragon", Position { x: 10, y: 4 })
It searches every table by name and returns Option<Entity>. Four recipes follow – looking at something now, one in a test, one from game code mid-run, and loot the way the dungeon rolls it. Take the one you are standing in.
Recipe 1: look at it right now
No code. NIHILURK_SPAWN drops names on free tiles around the player on every floor it builds:
cargo run -p nihilurk -- -content # 1. what are the names?
cargo run -p nihilurk -- -content | grep -i wand
NIHILURK_SPAWN="dragon" cargo run -p nihilurk # 2. put one in front of me
NIHILURK_SPAWN="bow,arrow,arrow,dart trap" cargo run -p nihilurk
Gear and ammunition take dressing: cursed (or megacursed), +N/-N and xN.
To press keys in the game it starts and read the screen back as text, with no terminal of your own, use play-through-a-pty.md.
NIHILURK_SPAWN="cursed -2 long sword,+3 ring mail,arrow x13" cargo run -p nihilurk
Names are comma-separated and trimmed; a name the tables do not know is skipped silently, so check it against -content rather than trusting an empty floor. Full rules: ../reference/cli-and-env.md.
Recipe 2: one specific thing in a test
-
Build a world. A bare one is enough to spawn into and assert about, and needs no resources at all:
use bevy_ecs::prelude::*; use models::*; let mut w = World::new(); let dragon = spawn_named(&mut w, "dragon", Position { x: 5, y: 5 }).unwrap();That is what
models/tests/it/content.rsdoes for all hundred-odd rows. Use it whenever the thing itself is the subject – its components, its name, its glyph. -
Or build a real floor, when the thing has to be somewhere – a monster that walks, an item auto-explore has to find, anything that reads
Map:fn test_world(seed: u64) -> World { let mut w = World::new(); w.insert_resource(GameRng(ChaCha12Rng::seed_from_u64(seed))); w.insert_resource(RngSeed(seed)); w.init_resource::<GameLog>(); w.insert_resource(PlayerName { what: "TESTER".into() }); initialize_world(&mut w); // map, player, starting kit, floor 1 w }Copy it from
models/tests/it/identify.rs, and add the queue your system reads (w.init_resource::<UseQueue>(),ThrowQueue,AttackQueue) if you are about to run one. The player isw.query_filtered::<Entity, With<Player>>().single(&w). -
Put it where the test needs it. Three places a thing can be, and the second and third are alternatives:
// on the floor -- it already is, from the Position you passed let pos = Position { x: 5, y: 5 }; let sword = spawn_named(&mut w, "long sword", pos).unwrap(); // in the pack -- strips Position, merges ammo, respects PACK_CAPACITY stow(&mut w, player, sword); // or worn/wielded -- no log line, no turn, no pack slot needed assert!(equip_silently(&mut w, player, sword));initialize_worldhands the player a starting kit – ring mail, a mace, a short bow – and that kit is already on.Slot::BodyandSlot::Handhold one thing each, soequip_silentlyreturnsfalsefor a second suit of armour until you take the first one off:if let Some(worn) = equipped_in(&w, player, Slot::Body) { force_unequip(&mut w, worn); }If the kit competes with what you are testing in the pack rather than on the body, empty the pack first –
empty_packinmodels/tests/it/identify.rsis the pattern. -
Name it in a literal and let it panic.
MonsterDef::named("dragon")andTrapDef::of(TrapEffect::Bear)panic on a miss, which is what you want from a typo in a test. Savelookupfor names that came from outside the source.
Recipe 3: one from game code, mid-run
The scroll of create monster (models/src/items/scrolls.rs, create_monster) is the worked example. Four steps, in this order:
-
Find a free tile. Never assume one:
let spot = free_adjacent_tile(world, origin) // next to someone .or_else(|| random_open_tile(world)); // anywhere on the floor let Some((x, y)) = spot else { world.resource_mut::<GameLog>().add("...nothing happens."); return; };Both read the
Mapand both can fail. The refusal branch is not optional – a sealed-in player is a legal floor.These two are inside
modelsonly (crate::helpersandcrate::traps, neither re-exported). From a test or fromengine/, either pick the tile yourself againstMap::blocksor callspawn_list, which does the searching for you. -
Roll from
GameRng, in a scoped block. The live run rolls fromGameRngand never fromlayout_rng/content_rng– those two belong to floor generation, and borrowing one while spawning will not compile anyway:let idx = { let mut rng = world.resource_mut::<GameRng>(); rng.0.gen_range(0..BESTIARY.len()) }; // <- borrow ends here, before the spawn -
Spawn through the right door:
let pos = Position { x, y }; spawn_monster(world, &BESTIARY[idx], pos); // monsters spawn_named(world, "long sword", pos); // items, by name world.spawn(TrapBundle::from_def( // a trap is a bundle, TrapDef::of(TrapEffect::Bear), // not an entity TrapReveal::Sight, pos, ));spawn_monsteris the only way a monster should enter the world: it attaches the row’s granted effects and itsInvisiblemarker.TrapBundle::randomis the rolled version, but it wants aChaCha12Rngof its own – floor generation hands it acontent_rng. From a live system, name the trap you mean. -
Say so in the log. Use
item_label(world, entity)(helpers, models-internal again) for the name the player is allowed to know – an unidentified suit of armour is “leather armor”, not “+2 leather armor” – andarticle_for(&name)for the “a”/“an” in front of it.
Recipe 4: loot the way the dungeon rolls it
spawn_named gives you the row. roll_item gives you a drop:
let item = roll_item(world, &mut rng, depth, pos);
Weighted category, weighted row within it, then spawn_as_loot – so it arrives enchanted, charged and bundled, gated to the depth you passed. This is the only place the dungeon decides what loot exists; call it rather than re-rolling quality yourself.
The rng is the caller’s, not GameRng: floor generation passes its own content_rng(seed, depth, changes), which is what keeps a floor’s contents keyed to the seed and the staircase count rather than to the fighting. A test can pass any ChaCha12Rng::seed_from_u64.
To roll one piece of quality onto something you spawned by name:
let sword = spawn_named(world, "long sword", pos).unwrap();
enchant_equipment(world, &mut rng, sword); // +1..+3, or cursed
What comes out is the bare row
spawn_named and the spawn_* wrappers build the thing exactly as its table row describes it – no dice are rolled. What that means per kind:
| You spawn | You get | Not |
|---|---|---|
| a wand | Battery { charges: WAND_CHARGES }, full, same as a drop | a battery rolled off constants::wands |
| a weapon, armour, a ring | no bonus, no Curse | an enchantment roll |
| arrows, quarrels | Stack { count: 1 } | a bundle |
| a trap | TrapReveal::Sight, so you can see what you placed | a rolled reveal style |
| a monster | the row, effects and invisibility included | anything depth-scaled |
That is deliberate: a test that spawns a thing needs it to be the same thing every time. When you want the randomised version, use Recipe 4.
When it does not work
spawn_named returned None. The lookup is a case-sensitive exact match on the row’s name – "Dragon" and "long Sword" find nothing. Check cargo run -p nihilurk -- -content.
It spawned but nothing is drawn. Either it has no Position (being in a Backpack means exactly that: stow removes it, and the renderer draws the pack separately), or the tile is not currently visible – floor items are only drawn on revealed tiles, and a monster gets Hidden until the player’s viewshed reaches it.
It landed in a wall. Nothing in the spawn API validates the tile you hand it; Position is whatever you said. Use free_adjacent_tile, random_open_tile, or spawn_list, which searches outward in rings and skips anything Map::blocks.
equip_silently returned false. Either the item has no Equipped component (it is not gear), or the slot is full – and on a world built by initialize_world the body and hand slots start full. force_unequip the incumbent first (Recipe 2, step 3).
The borrow checker refuses the RNG line. A resource_mut::<GameRng>() guard is still alive at the spawn. Roll inside a block and let it drop (Recipe 3, step 2).
Two things landed on the same tile. Only spawn_list and spawn_requested track occupancy, via the &mut HashSet<(u16, u16)> you pass in. Spawning in a loop yourself means keeping that set yourself.
A seeded test started failing after you added content. Floor layout is a pure function of (seed, depth) and cannot move – but the contents are drawn from a weighted table, so adding a row changes which rows a given roll lands on. Assert on what you spawned by name, not on what a seed happened to produce. models/tests/it/determinism.rs holds the line that matters.
Check yourself
cargo test --test it content:: # every name still spawns and keeps its name
cargo test --test it determinism:: # seeds still mean what they meant
cargo run -p nihilurk -- -content # what the game knows, live from the tables
Appendix: quick check
- Look at it now:
NIHILURK_SPAWN="<name>" cargo run -p nihilurk, with names from-content. - In a test:
spawn_named(&mut w, "<name>", Position { x, y }).unwrap()on a bareWorld. - Needs a floor: build the world with
initialize_world, asmodels/tests/it/identify.rsdoes. - In the pack:
stow(&mut w, player, item). Worn:force_unequipthe incumbent, thenequip_silently. - From game code: find a free tile, roll from
GameRngin a scoped block, then spawn throughspawn_monster,spawn_namedorTrapBundle::from_def. - Many at once:
spawn_list, which tracks occupancy. - Loot as the dungeon rolls it:
roll_item(world, &mut rng, depth, pos). - Run
cargo test --test it content::andcargo test --test it determinism::.
See also
../reference/spawn-api.md every signature on this page ../reference/cli-and-env.md NIHILURK_SPAWN and -content in full ../reference/content-tables.md the tables these functions read add-a-monster.md adding the row you want to spawn add-an-item.md the same, for items work-with-the-ecs.md spawning from inside a system play-through-a-pty.md look at it in the real game, from a script
How to tune rarity and depth
Audience Content author balancing a floor.
Prerequisites You have added something and now want it to show up
less, or later, or at all.
Result You know which of the four dials to turn, and which
ones you should leave alone.
There are four places rarity is decided, and they answer different questions. Turning the wrong one is the usual mistake.
1. How many things per floor? engine knob, map/population.rs
2. Which category of item? DROPS, models/src/spawn.rs
3. Which row within a category? the row's own weight
4. Is it allowed here at all? the row's min_depth
How weights work
An entry’s chance is its weight over the sum of the weights it competes against, and it competes only inside one draw: a monster’s weight against other monsters, a DROPS weight against other categories.
The default weight is the baseline (constants::monsters::DEFAULT_SPAWN_WEIGHT for the bestiary). A row at half of it is half as common as its neighbours; one at double it is twice. Nothing has to total anything, so you can add a row without editing another number. Weights are whole numbers, so 1 is the floor: to make a row rarer than 1 you raise the baseline and every explicit weight with it (spirits did exactly this).
Dial 1: how many things per floor
This is not content. It is floor generation, in populate_level in models/src/map/population.rs, and it is the same for every row.
Everything scales off tier – map::difficulty_tier(depth), which steps at the depths in constants::progression::DIFFICULTY_TIER_LAST_DEPTH: tier 0 on the shallowest floors, and the deepest floor a tier of its own. The damage traps scale too, but on their own coarser bands (constants::traps::TRAP_DAMAGE_TIER_LAST_DEPTH). Every name below is in constants::population.
guaranteed placed before any budget is spent, and never out of
one: a blue coin on the odd floors and a red one on
the even. On the last floor of each tier -- the
depths in DIFFICULTY_TIER_LAST_DEPTH -- one draw from
`catalog::PROGRESSION_ITEMS` as well.
monsters MONSTER_SLOTS_BASE + tier slots. The first always
fills; each later one fills with probability
MONSTER_FILL_CHANCE_BASE + MONSTER_FILL_CHANCE_PER_TIER
* tier, capped at MONSTER_FILL_CHANCE_CAP.
lurkers from floor CORRIDOR_LURKER_MIN_DEPTH, each corridor
centre has a CORRIDOR_LURKER_CHANCE chance of hiding
one more.
items ITEM_SLOTS_BASE + tier attempts. Every attempt that
finds a free tile drops an item (no fill roll).
hidden item HIDDEN_ITEM_CHANCE of floors hide one more in plain
sight -- no glyph until you walk onto it.
traps TRAP_SLOTS_BASE + tier slots, each filling with
probability TRAP_FILL_CHANCE_BASE +
TRAP_FILL_CHANCE_PER_TIER * tier, capped at
TRAP_FILL_CHANCE_CAP.
Change these when the dungeon feels too empty or too crowded. Do not change them to make one creature rarer – that is dial 3.
Turning any of these cannot move a wall. A floor’s layout is built from
layout_rng(seed, depth)and its contents fromcontent_rng(seed, depth, changes)– neither is the shared run stream, so nothing a player does mid-fight can shift a floor. (The contents do re-roll each visit, off the staircase count.) What your weights change is what a given seed produces from the table you edited, which is the whole point of editing it. See../explanation/data-driven-content.md.
Dial 2: which category of item
models/src/spawn.rs -> DROPS
category!("scroll", <weight>, 1, SCROLLS),
category!("potion", <weight>, 1, POTIONS),
...
| |
weight min_depth
Those weights are Rogue’s own drop odds in tenths of a percent. ../reference/content-tables.md carries the current table, and a test holds it in step with DROPS. They do not have to total anything round, which is only convenient to read.
To double how often rings turn up, double the ring row’s weight. Every other share drops slightly to pay for it, automatically.
To keep a category out of the shallow dungeon, raise its min_depth. A category that cannot appear is simply not in the draw, and the remaining categories divide its share between them in proportion.
The rune row is the example. Its min_depth is above 1, so the shallowest floors draw from every other category and the rune joins the draw below that. The weights table in ../reference/content-tables.md shows the share with the rune in the draw, and says what the shallower floors total. models/tests/it/loot.rs scales a category’s expected share by the floors it is on, so a new category that starts deeper needs no special case there.
Dial 3: which row within a category
Monsters carry the dial on the row:
MonsterDef::row("dragon", ...).weight(3),
Traps carry it as a field:
TrapDef { effect: ..., weight: 3, min_depth: 9, ... },
Items do not have per-row rarity today, on purpose: within a category nihilurk picks evenly, and the design likes it that way. The hook exists if you want it – ItemDef::weight() and ItemDef::min_depth() are trait methods with defaults of 10 and 1, and the picker already honours them. To give one category per-row rarity, add a weight: u32 field to its Def struct and return it:
impl ItemDef for ArmorDef {
fn name(&self) -> &'static str { self.name }
fn weight(&self) -> u32 { self.weight } // <- added
...
}
Then every ArmorDef row must carry a weight, which is the cost. Decide whether the category deserves it before you pay it.
Dial 4: is it allowed here at all
min_depth is the shallowest floor a thing may appear on. It is a hard gate, not a weight: below it the row is not in the draw.
Monsters use it as the last column of the row:
// name glyph colour move hp pow pb ar ab dep
MonsterDef::row("dragon", 'D', Color::Red, Chase, 8, 12, 2, 10, 2, 10),
The bestiary uses 1, 5 and 10 – the shallow stat band from the start, the middle band from floor 5, the nastiest letters from floor 10. Nothing stops you using 2 or 11.
The dungeon is FINAL_DEPTH floors deep, so a min_depth above that means “never” – on the way in. The climb out is the exception: once the Element of Yoord is in the pack, floor population switches from pick to MonsterDef::pick_any, which drops the gate entirely, so a min_depth of 10 (or 99) is no protection on the ascent.
Every floor still draws from everything it has unlocked. A bat does not stop appearing on floor 9; it competes with the dragon. That is what keeps deep floors feeling like a dungeon rather than a boss rush. If you want something to stop appearing,
min_depthis the wrong dial – there isn’t one, and adding one is an engine change.
Verify what you changed
The table tests cover the gating but not your intent, so measure it:
cargo test --test it content::
every_loot_category_can_be_drawn_and_has_rows_to_give asks the table, not a sampler: every category keeps a nonzero weight and has rows at its debut floor and at the deepest. If you weight a category down to nothing, that test tells you.
For a feel, the quickest instrument is a throwaway loop over roll_item or MonsterDef::pick counting names – see models/tests/it/content.rs for the shape.
Appendix: quick check
- Decide which question you are asking: how many per floor, which category, which row, or allowed here at all.
- Per floor: edit
populate_levelinmodels/src/map/population.rs; this moves every row at once. - Category: change its weight in
DROPS(models/src/spawn.rs). - Row:
.weight(n)on a monster, orweighton a trap; twenty is the baseline (monsters and traps; spirits sit at 1, the floor) and items have no per-row weight. - Gate: raise
min_depth; it is a hard gate, and nothing makes a row stop appearing deeper. - Run
cargo test --test it content::. - Count what a draw gives you with a throwaway loop over
roll_itemorMonsterDef::pick. - Fix the docs: if you changed a
DROPSweight, update the weights table in../reference/content-tables.mdand the listing in this page.
See also
../reference/spawn-api.md pick_weighted, roll_item, DROPS ../reference/content-tables.md where weight and min_depth live add-an-item-category.md adding a row to DROPS itself
How to update the docs after you add something
Audience Anyone who just changed the game and is about to
open a pull request.
Prerequisites None.
Result You know which pages your change made false, and
you fix them before review.
Most additions need no docs edit. The tables are short, cargo run -p nihilurk -- -content prints them live, and no page lists a monster, a weapon or a trap by name. This page lists the exceptions: the places where a page keeps a hand-written list of something. Find your change below, touch those pages, run the check.
Paths are relative to this page, except MANUAL.md, doc/, strings/ and engine/, which start at the repo root.
Look it up
| You added or changed | Touch | Because |
|---|---|---|
| A monster, weapon, armour, coin, ammo, launcher or treat | nothing, unless it brings a new mechanic (see below) | -content prints every row |
| A ring | ../reference/components.md, the RingEffect line | the enum is pasted there |
| A potion | ../reference/components.md, the PotionEffect line | the enum is pasted there |
| A scroll | ../reference/components.md, the ScrollEffect line; ../explanation/the-feel-layer.md, “What a scroll looks like” | the enum is pasted there; each scroll has a flourish |
| A wand | ../reference/components.md, the WandEffect line; ../reference/content-tables.md, the wand table; ../explanation/the-feel-layer.md, “What a wand looks like” and the thrown-wand list | one table row per wand; a thrown wand blasts by kind |
| A spell | nothing, unless it adds a constants:: module (see below) | SPELLS is not in -content, but no page lists spells |
| A trap | ../reference/components.md, the TrapEffect line | the enum is pasted there |
| An effect | ../reference/content-tables.md, one row in the EFFECTS table | the table has one row per id |
| An ability | nothing | the ABILITIES page describes fields, not rows |
| A playable body | ../reference/cli-and-env.md, the -b section; ../reference/components.md; ../reference/constants.md; MANUAL.md, “Who goes down”; doc/nihilurk.6 | each says what the bodies are |
| A rule | ../reference/agents.md, the rules table | one table row per rule |
| A rule set | ../reference/agents.md, the sets table | one table row per set |
| A whole item category | ../reference/content-tables.md, “Where everything is”, a section for the new Def, and the DROPS weights table; add-an-item.md, “At a glance”; tune-rarity-and-depth.md, the DROPS listing | each lists the categories |
| A kind of entity that is not an item, monster or trap | ../reference/content-tables.md, a section for its table; ../reference/components.md, anything new it carries | the table and the components are the register |
A constants:: module | ../reference/constants.md, one row in the modules table | nothing else checks it |
| A component, resource or event | ../reference/components.md | it is the register |
| A system in the turn schedule | ../reference/input-and-turn-loop.md, the schedule list | the order is the page |
| A key | MANUAL.md, “Useful commands” and the quick reference; doc/nihilurk.6, “GAME CONTROLS”; ../reference/input-and-turn-loop.md, the key table | three places list keys |
| A flag or an env var | ../reference/cli-and-env.md; doc/nihilurk.6; print_help in engine/src/main.rs | the -h text is separate from the page |
| A player-facing sentence | a new function in strings/src/en.rs | see “Player-facing text” below |
| The install line, the languages or the license | ../../site/llms.txt | it repeats them for AI crawlers |
| A new page | ../README.md, one row in the index; a See also block | an unlisted page is not found |
A secret (the T key, the -pride flags) stays out of MANUAL.md and ../reference/cli-and-env.md. See ../explanation/documentation-style.md.
When the change is a mechanic
If your change alters what a player can do or has to know (polymorph lends powers, a staff changes what spells cost, a trap now fires for monsters), the rule belongs in MANUAL.md in the player’s voice, and in the reference page for the system it touches. ../../gdd.md changes only when the design does, not when the code does.
If your change settles or reverses a design question, write an ADR in the shape of ../explanation/adr-0001-tables-not-raws.md and add it to the list in ../README.md. The history goes there, and nowhere else.
Player-facing text
Every sentence the player reads is a function in strings/src/en.rs. es.rs, pt.rs and ht.rs re-export English until someone translates the function, so a new English function needs no other file. If a language stops building, run lua .githooks/stub_missing_translations.lua. English you write starts unreviewed: the pre-commit hook tags each new en.rs function // TODO: placeholder English; needs a human's pass., and whoever has read it and stands behind it deletes the tag, reworded or not.
The three habits
- Name the constant, not its value. A number copied into prose goes stale on the first rebalance. The same goes for counts: “nine tables” and “all six traps” were each wrong within a release. Say “every table”.
- Search for what you renamed.
docs_style.shchecks paths and links, not identifiers. After a rename or a removed mechanic, rungrep -rn 'old_name' docs MANUAL.md doc. - Cite the test. If a claim can be a test, make it one and name it in the page.
Check
./docs_style.sh # house style, links, paths
cargo test --test it content:: # the claims the how-tos make about the tables
grep -rn 'old_name' docs MANUAL.md doc # what your rename left behind
With git config core.hooksPath .githooks on, the pre-commit hook runs ../../.githooks/docs_nudge.lua and names the pages from the table above that your staged change leaves out. It reminds and never refuses. A row you add to the table above needs a rule in ../../.githooks/docs_nudge_rules.lua, or a stated reason no diff can see it; ../../.githooks/docs_nudge_test.lua refuses the commit while the two disagree.
See also
add-an-item.md the recipes this page follows up ../explanation/documentation-style.md the shape and voice of a page ../README.md the index, and the rule that keeps docs true ../reference/constants.md the modules table you add a row to
Working with the ECS
Audience Engine developers. You are adding a mechanic, not a
row in a table — you need to reach an entity, change
it, and get the borrow checker to let you.
Prerequisites You write Rust. You know what a component and a
system are. You have read
`../reference/components.md` at least once.
The snippets are written from inside models, so they say crate::conditions::.... From engine or a test, the same name is models::... (see ../reference/spawn-api.md for what the crate root exports).
Recipes: copy the shape, change the nouns. Why the code is arranged this way is ../explanation/ecs-in-nihilurk.md.
Sixteen of nihilurk’s seventeen schedule steps take &mut World and nothing else. That one fact decides everything below: you are not writing Query<&mut Fighter> and letting bevy sort out the aliasing, you are holding the whole world and borrowing bits of it by hand. The borrow checker is stricter here than it is in a Query-based codebase, and the five patterns in the first section are how every mechanic in the tree gets past it.
Contents
The five borrow patterns start here
Recipe: add a component
Recipe: add a kind of entity
Recipe: put something in the world
Recipe: deal damage
Recipe: apply a condition
Recipe: read a stat gear contributes to
Recipe: queue an intent from input
Recipe: add a system to the turn
Recipe: decorate without depending on the renderer
Recipe: write a system that really is a Query
Checking your change
The five borrow patterns
1. Copy out, then mutate
world.get::<C>(e) hands back Option<&C>, and that & borrows the whole world. While you hold it you cannot log, roll a die, or touch another component. So take a copy and drop it on the same line:
let Some(pos) = world.get::<Position>(victim).copied() else {
return;
};
// `world` is free again from here.
crate::shake::kick_shake(world, ShakeKind::Kill);
.copied() for a Copy component, .cloned() or .map(|n| n.what.clone()) for a String, .map(|f| f.hp) when you only want one field. The let ... else is the house guard clause — see ../explanation/code-calisthenics.md.
The same rule in its most common disguise: name things before you log them. This does not compile if you inline item_label, because the log is a resource and the name is a borrow of the world:
let name = item_label(world, entity); // borrow ends here
if let Some(mut mob) = world.get_mut::<Mob>(entity) {
mob.movement_type = MovementType::Confused;
}
world.resource_mut::<GameLog>()
.add(format!("The {name} {mob_verb}."));
(conditions::stagger.) A Mut<C> from get_mut behaves the same way — its borrow ends at its last use, not at the end of the block, so a write followed by a resource_mut is fine as long as nothing touches the Mut afterwards.
2. Collect, then iterate
world.query::<…>() needs &mut World even to read, and the QueryState it returns borrows the world for as long as you iterate it. Any loop body that mutates must therefore run after the iteration ends. Collect the entity ids first:
let doomed: Vec<Entity> = {
let mut q = world.query::<(Entity, &Fighter)>();
q.iter(world)
.filter(|(_, f)| f.hp <= 0)
.map(|(e, _)| e)
.collect()
};
for entity in doomed {
finish_indirect_kill(world, entity, None);
}
(combat::reaper_system.) Collect ids, not references — the whole point is to stop borrowing. And re-check anything you collected before you use it: by the time the loop reaches the third entity, the first two may have despawned it, teleported it or turned it into something else. traps::detonate_trap does exactly that:
if world.get::<Position>(victim).is_none() {
continue; // an earlier victim's trapdoor took this one with it
}
3. mem::take a queue before you drain it
A system that drains a queue resource cannot hold that resource while it resolves, because resolving touches everything. Swap the queue’s contents out for an empty Vec in one move:
pub fn combat_system(world: &mut World) {
let attacks = std::mem::take(&mut world.resource_mut::<AttackQueue>().attacks);
for attack in attacks {
resolve_attack(world, attack.attacker, attack.target);
}
}
All five queues (AttackQueue, UseQueue, ThrowQueue, SpellQueue, PlayerActionQueue) are drained exactly like this. It also gives you the right semantics for free: an attack queued while the queue is draining lands next turn, not in the middle of this one.
4. entity_mut for a burst of writes
world.entity_mut(e) returns an EntityWorldMut that holds the world until you drop it. Inside it, insert/remove/get/contains are cheap and archetype-aware — so batch structural changes there rather than calling world.entity_mut(e) once per component:
let mut e = world.entity_mut(item);
if e.contains::<PowerDie>() && e.contains::<PowerBonus>() {
e.insert(PowerBonus(0));
}
e.remove::<Curse>();
(items::wands::cancel_player.) What you cannot do inside that block is call anything taking &mut World — including world.resource_mut(). Gather the facts, close the block, then log.
5. &World can read but not query
A helper that only reads should take &World, so its callers can hold one alongside other borrows. The cost is that world.query() is off the table; you get world.get::<C>(e) and world.iter_entities() and nothing else.
pub fn equipped(world: &World, wearer: Entity) -> impl Iterator<Item = EntityRef<'_>> {
world
.iter_entities()
.filter(move |e| e.get::<Equipped>().is_some_and(|eq| eq.by == Some(wearer)))
}
(equipment::equipped.) Prefer returning an iterator of EntityRef over a Vec<Entity>: the caller gets to read components straight off it with no second lookup and no allocation. Keep a Vec-returning twin (equipped_items) only for callers that go on to mutate.
Recipe: add a component
Before you write one, check you need one. Three things that look like new components are not:
- A property some system will ask about — fire immunity, sees invisible, hits harder. That is an effect: a marker in
effects.rsplus a row in theEFFECTSregistry, so a table can name it withGrant::of::<C>()and a wand of cancellation can strip it. Followadd-an-effect.mdinstead. - A number gear contributes — a weapon’s die, a ring’s plus. That is a
Modifierineffects.rs;equipped_totalalready folds it. - Which one of a kind a thing is — a fourteenth wand, a sixteenth potion. That is a variant on an existing type key and a row in
catalog.rs. Seeadd-an-item.md.
What is left is a genuinely new fact about an entity, and it goes in models/src/components.rs — nouns only, no behaviour:
/// Two sentences: what carrying this means, and who owns it. Name the
/// system that reads it, and — if it is transient — say so here,
/// because that doc comment is the only record of why it is missing
/// from the save.
#[derive(Component)]
pub struct Doomed {
pub turns: u32,
}
Then, in order:
- Derive no more than it needs.
Componentalways.Clone, Copywhen it is small and gets read out by value.Serialize, Deserializeonly if the save stores the type itself rather than its fields — most do not. - Decide if it is saved, and write the answer in the doc comment. If yes: a field appended to
saveload::EntitySave, plus the save line, the load line and a newSAVE_VERSION. If no, say why — rebuilt every frame (Viewshed::visible_tiles), reset on load (Speed::energy), or re-attached from a catalog row by id (ThrownDamage). A component that is silently neither is a bug waiting for somebody’s save file. - Give it a row in
../reference/components.md, in the section it belongs to, with its “Saved?” column filled in.
Rules that are not negotiable:
- No
fnthat touches theWorld. If the component needs a verb, the verb lives in the module that owns the domain —conditions.rsfor an affliction,equipment.rsfor gear. Methods about the type (SpeedKind::faster,Name::article) are fine. - A saved enum is written by variant position. Append variants; never reorder one, or a saved trapdoor comes back as something else.
- Small. A component with eight fields is usually two components.
Attaching and detaching one
world.entity_mut(e).insert(Doomed { turns: 3 });
world.entity_mut(e).remove::<Doomed>();
Both are structural — they move the entity between archetypes — so they invalidate any query you are in the middle of. That is what pattern 2 (collect, then iterate) is for. From a Query system, defer them through Commands instead:
commands.entity(entity).insert(Hidden);
Recipe: add a kind of entity
“Kind” means a new shape — a thing that is neither a monster, an item, nor a trap. Adding a new monster or a new potion is a table row and nothing else; see add-a-monster.md and add-an-item.md.
A new kind is three pieces, and the third is what keeps it from spreading:
1. A Def row type, with the table beside it. Not in components.rs — next to the content it describes, so the type, the table and the one place it is built are all in one file:
pub struct TrapDef {
pub effect: TrapEffect,
pub name: &'static str,
pub glyph: char,
/* … the dials its mechanic needs, on the row */
}
pub const TRAPS: &[TrapDef] = &[ /* one line per trap */ ];
2. A Bundle, so the entity is described exactly once. Every spawn site goes through it — level population, a scroll that conjures one, a save being reloaded — and none of them assembles the components by hand:
#[derive(Bundle)]
pub struct TrapBundle {
pub name: Name,
pub glyph: Renderable,
pub position: Position,
pub trap: Trap,
pub hidden: Hidden,
}
impl TrapBundle {
pub fn from_def(def: &TrapDef, reveal: TrapReveal, position: Position) -> Self { … }
}
let e = world.spawn(TrapBundle::from_def(def, reveal, pos)).id();
Components that only some of the kind carry stay off the bundle and go on afterwards, the way spawn_monster adds Invisible for the phantom:
let e = world.spawn(MonsterBundle::from_def(def, pos)).id();
grant_all(world, e, def.grants);
if def.invisible {
world.entity_mut(e).insert(Invisible);
}
3. A way to reach it by name. Add the table to spawn::spawn_named and to spawn::content_names, and the new kind immediately answers to NIHILURK_SPAWN, to -content, to every test, and to models/tests/it/content.rs’s “every name spawns and keeps its name” check. If it is loot rather than furniture, implement ItemDef and give it a DROPS row instead — add-an-item-category.md is the full recipe.
Then the bookkeeping, in order:
- Saving. Decide what the save stores and what it reads back off the row. Store the row’s name, never its contents (see
restore_from_catalog), and add the entity’s own mutable state — a trap’srevealedlatch, a wand’s charges. - Despawning between floors.
map::levels::transition_levelclears everything with aPositionthat is not the player and not in a backpack. If your kind should survive a staircase, it needs a reason and an exception; if it should not, you have nothing to do. - Drawing. A
Renderablegets it on screen, but when it draws is a layer inengine/src/view.rs’srender, and the order matters — a later layer covers an earlier one. See../reference/rendering.md. - Documenting. A row in
../reference/content-tables.mdfor the table and one in../reference/components.mdfor anything new it carries.
The test that tells you it landed: cargo run -p nihilurk -- -content lists it, and NIHILURK_SPAWN="<name>" puts one in front of you.
Recipe: deal damage
%%{init: {'theme':'base','themeVariables':{
'primaryColor':'#20242b','primaryTextColor':'#d7dae0',
'primaryBorderColor':'#5c6370','lineColor':'#8a8f98',
'fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace',
'fontSize':'13px'}}}%%
flowchart LR
SW(["somebody swung"]):::hero --> RA["combat::resolve_attack"]:::peril
RA --> R1["opposed rolls<br/>+ gear"] --> R2["chip floor,<br/>melee cap"]
R2 --> R3["ABILITIES<br/><i>Moment::OnHit rows</i>"]:::magic --> R4["blood, promise,<br/>low-HP warning"]:::peril
R4 --> R5["log, shake,<br/>score, despawn"]
NO(["a trap, a bolt,<br/>a blast"]):::magic --> AD["helpers::apply_damage"]:::peril
AD --> A1["HP off"] --> A2["blood, promise,<br/>low-HP warning"]:::peril
A2 --> RP["reaper_system<br/><i>at the tail of the turn</i>"]:::peril
A2 -. "or, if you know the blast centre" .-> FI["finish_indirect_kill"]:::peril
classDef hero fill:#3a3418,stroke:#d7ba4a,color:#e8dfa8
classDef peril fill:#3a1f1f,stroke:#c05050,color:#f0c8c8
classDef magic fill:#2f2038,stroke:#a86fc0,color:#e6cdf0
classDef cold fill:#17323a,stroke:#4aa3c0,color:#bfe4f0
There are exactly two ways, and which one you want depends on whether somebody swung.
Something hit something else — use the opposed-roll resolver:
crate::combat::resolve_attack(world, attacker, target);
It rolls both sides, folds in every equipped modifier, fires every Moment::OnHit row in ABILITIES, spills blood, logs the line, kicks the shake, pays the score and despawns the corpse. You do not do any of that yourself. Monsters reach it by pushing onto the AttackQueue (below); the player’s own swing calls it inline from move_player.
Something took damage with no swinger — a trap, a bolt, a blast:
crate::helpers::apply_damage(world, victim, amount);
That subtracts the HP, spills blood, breaks any promise the victim was holding and fires the low-HP warning. It deliberately does not kill: anything left on hp <= 0 is finished by reaper_system at the tail of the turn. If you know where the damage came from and want the corpse flung away from it, finish the casualty yourself instead:
if world.get::<Fighter>(victim).is_some_and(|f| f.hp <= 0) {
crate::combat::finish_indirect_kill(world, victim, Some(center));
}
(items::wands::elemental_blast.)
Three rules that are easy to get wrong:
- Armour. Melee, missile, magic:
resolve_attackrolls the defender’s armour die and plus; a throw or a shot subtracts only the armour plus (helpers::total_armor_plus); a spell or wand subtracts nothing. An arrow or dart trap is a missile; every other damage source is magic. No exceptions. See../explanation/combat-and-balance.md. - Elements. If the damage has a flavour, go through
items::wands’sdamage_with_element, which checks the target’s immunity (Element::immunity()) and logs the shrug. Never match on a species. took_damage. Melee applies its own HP change and so has to callhelpers::took_damageby hand. If you ever add a third damage path, call it too — it is the one place “what happens because a creature was hurt” lives.
Recipe: apply a condition
Never insert a condition component directly. Call the verb in crate::conditions, which already knows the player takes an affliction differently from a monster:
use crate::conditions::{blind, confuse, paralyse, hasten, snare, shift_entity_speed};
// The player gets `Confused`; a monster gets `MovementType::Confused`.
confuse(
world,
victim,
"The flash leaves you reeling — you are dazzled!", // the player reads this
"is dazzled", // "The rat ___."
);
// Pinned for a count of turns. Deliberately silent — the sentence
// belongs to whatever pinned them.
snare(world, victim, Grant::of::<Asleep>(), SLEEP_TURNS);
Every verb returns bool: whether it actually took hold. That answer is load-bearing — a potion thrown at a monster only identifies itself when something plainly happened (items::potions::detonate_potion), so pass it up rather than discarding it.
To add a new condition, in order:
- A marker component in
components.rs, doc-commented with what it costs the player and what it does to a monster instead. - A verb in
conditions.rsthat does the player/monster split once. - An arm in
conditions::cure_one_condition(a rosé coin and a ring of regeneration both lift “the worst thing wrong with you”, worst first) and inconditions::afflicted. - An arm in
conditions::clear_player_conditions, unless it is meant to survive a staircase —PlatedandForgedare the only two that do, and the staircase settles them instead. - A field appended to
saveload::EntitySave, plus the save and load lines and a newSAVE_VERSION. A condition a save forgets is a condition that silently lifts on reload. - A badge in the HUD’s condition list (
engine/src/view.rs).
A condition that lives on a monster usually needs none of this: prefer a MovementType or a Speed shift, which the AI already reads.
Recipe: read a stat gear contributes to
Never walk the pack. One call folds the entity’s own component together with every equipped source of the same modifier:
use crate::effects::{equipped_total, PowerDie, PowerBonus, ArmorDie, ArmorBonus, ThrowBonus};
let power = world.get::<Fighter>(e).map_or(1, |f| f.power)
+ equipped_total::<PowerDie>(world, e);
It does not know what kind of item supplied the number, and neither should you: that is the whole reason a ring of protection needs no code. Combat, the throw roll, the trap damage rule and the HUD all fold through it, which is why the HUD can never drift from the dice.
Want more than one of them? Take the whole loadout in one pass. Each equipped_total walks the wearer’s gear again, so five fields is five walks over the same handful of items:
let worn = crate::effects::loadout(world, entity);
let power = world.get::<Fighter>(e).map_or(1, |f| f.power) + worn.power_die;
let cap = worn.melee_cap; // ceilings fold with `min`, not `+`
combat::fold_matchup takes one per side; the HUD takes one per frame. There is no cache underneath it — Equipped is still the only record of who wears what.
To ask whether an entity has a property, probe the marker, never the source:
if world.get::<SeesInvisible>(e).is_some() { … }
A ring, a potion and being born that way all leave the same component.
Recipe: put something in the world
Anything the content tables know, by name:
let dragon = crate::spawn_named(world, "dragon", pos); // Option<Entity>
That is the door a test, a debug command and NIHILURK_SPAWN all use. It builds the thing exactly as its row describes it — no enchantment roll, no ammunition bundle. For the dungeon’s own randomised version, spawn::roll_item(world, rng, depth, pos).
Assembling an entity by hand is for one case only: you are adding a new kind of thing. Then it is a Bundle next to its table, never a pile of insert calls at a call site:
#[derive(Bundle)]
struct MonsterBundle { name: Name, mob: Mob, fighter: Fighter, /* … */ }
let e = world.spawn(MonsterBundle::from_def(def, pos)).id();
See spawn-a-thing.md for the full recipe and add-an-item-category.md for a new table.
Two things about removing:
world.despawn(e)is final. Anything still holding thatEntity— aBackpack, a queuedWantsToThrow— now holds a dangling id, so take it out of the pack first (items::scrolls::lift_cursesshows the shape).- An item picked up does not despawn: it loses its
Position. An item dropped gets one back. “Is it on the floor?” isWith<Item>, With<Position>.
Recipe: queue an intent from input
Input handlers do not resolve anything. They push an intent and return whether a turn was spent; the matching system drains it next time the schedule runs.
world.resource_mut::<AttackQueue>().attacks.push(WantsToAttack {
attacker: mob,
target: target_entity,
});
| Intent | Queue | Drained by |
|---|---|---|
WantsToAttack | AttackQueue | combat_system |
WantsToUse | UseQueue | item_system |
WantsToThrow | ThrowQueue | throw_system |
WantsToCast | SpellQueue | spell_system |
WantsToUse carries slot_idx so a surviving item goes back to the exact pack row it came from, and target for anything aimed. A throw of a stacked item goes through models::draw_one first, which splits one arrow off and leaves the quiver where it was. WantsToCast is an active spell’s own intent — a wand’s twin, minus everything about an item because a spell isn’t one.
Recipe: add a system to the turn
Register it in models/src/schedule.rs with an explicit .after(). There is no implicit ordering and no SystemSet in nihilurk — the schedule is one flat list of seventeen steps, and every edge is deliberate:
schedule.add_systems((
// …
combat_system.after(equipment_effects_system),
reaper_system.after(combat_system),
// …
));
Then say why the edge exists in ../reference/input-and-turn-loop.md, which prints the whole order. Before you add a step, check whether it belongs at the tail: ability_system and score_turn_system are both there on purpose (a passive that moves you must land at the top of your next turn; a combo cannot be totalled until the dying is over).
Recipe: decorate without depending on the renderer
Gameplay code arms cosmetics and forgets. It must never require them — tests build a bare world with no effect layer at all. So reach for the cosmetic resources through get_resource_mut, not resource_mut:
if let Some(mut fx) = world.get_resource_mut::<Particles>() {
fx.hit_spark(x, y);
}
The screen shake wraps that for you, so combat code never has to think about it:
crate::shake::kick_shake(world, ShakeKind::Heavy); // no-op with no Shake resource
Two more rules:
- Cosmetic rolls come off
FxRng, neverGameRng. A colour drawn from the gameplay stream would mean every firework quietly reshuffled the dice for everything after it.score::random_brightandhelpers::spill_bloodboth show the pattern. - Gate anything that leaks information on sight. A shake for a blast in a room the player has never entered tells them there is a room there.
helpers::player_sees(world, x, y)is the shared check.
Recipe: write a system that really is a Query
One system in nihilurk is Query-based (visibility_system), because it only reads and tags and never despawns. If yours is the same shape, write it that way — narrow filters are self-documenting and bevy checks the aliasing for you:
pub fn visibility_system(
mut commands: Commands,
mut viewshed_query: Query<
(Entity, &mut Viewshed, &Position, Option<&SeesInvisible>, Option<&Blind>),
With<Player>,
>,
mut log: ResMut<GameLog>,
map: Res<Map>,
) { … }
Three things to copy from it:
With<Player>is load-bearing, not decoration. Without it a future monster viewshed would reveal the map for the player.- Ask for
Option<&C>rather than a second query when a property is optional —SeesInvisiblemight come from a ring, a potion or birth, and this system never asks which. - Structural changes go through
Commands, which defers them past the end of the iteration. That is the whole reason this can be aQuerysystem at all.
Fetch nothing you do not read. A tuple that grows past what the body actually uses is the first sign a system is doing two jobs.
Checking your change
cargo test --workspace # everything
cargo clippy --all-targets # correctness / suspicious / complexity / perf
cargo fmt --all
grep -rn '} else' models/src engine/src --include='*.rs'
Every hit of that last one should be a let … else or a two-armed expression ternary; anything else is a regression against ../explanation/code-calisthenics.md.
And the one that catches an ECS mistake specifically:
cargo test --test it determinism::
It pins that a seed produces the same walls and the same floor contents it always has. If a change to when something is spawned moves that, you have drawn from the wrong RNG stream — see map::streams::content_rng.
Appendix: quick check
- Before a new component, check it is not an effect, a modifier, or a row of an existing kind.
- Component: add it to
models/src/components.rsas nouns only, decide and say if it is saved, and add its row to../reference/components.md. - New kind of entity: a
Defrow type and table, aBundle, thenspawn_namedandcontent_names. - Damage:
combat::resolve_attackif somebody swung,helpers::apply_damageif not; the reaper finishes the dead. - Condition: call the verb in
crate::conditions; a new one needs a marker, a verb, two match arms, a save field and a HUD badge. - Gear stat:
equipped_total::<C>, orloadoutfor several; probe the marker to ask a yes or no. - Spawn:
spawn_namedfor a row,roll_itemfor a drop; remove an item from the pack before you despawn it. - Input: push an intent onto its queue and return; the matching system drains it.
- System: register it in
models/src/schedule.rswith an explicit.after(), and explain the edge in../reference/input-and-turn-loop.md. - Cosmetics: use
get_resource_mut, roll fromFxRng, and gate anything that leaks onplayer_sees. - Run
cargo test --workspace,cargo clippy --all-targets,cargo fmt --all, andcargo test --test it determinism::.
See also
../explanation/ecs-in-nihilurk.md why the world is driven this way ../reference/components.md every component, resource and event ../reference/spawn-api.md the functions that build entities ../reference/input-and-turn-loop.md the schedule, in order add-an-effect.md adding a property rather than a mechanic add-an-item-category.md adding a whole new kind of item spawn-a-thing.md putting one specific thing on one tile ../explanation/code-calisthenics.md the shape this code is held to
Reference: agents
Audience Anyone changing what a monster or a Helper does with
its turn. Look things up here; do not read it end to
end.
Prerequisites None.
Status Describes `models/src/agents.rs` and the parts of
`models/src/ai.rs` that call it. If this page and the
source disagree, the source is right and this page is
a bug.
Every mob decides its turn the same way: ai builds a Percept, agents::think hands it to the mob’s RuleSet, and the first Rule that fires names one Action, which ai carries out. Nothing is remembered between turns. Why it is built this way is ../explanation/agents.md.
The turn, in order
models/src/ai.rs step_one_mob
- Gates. Dead (0 HP),
Asleep,Petrifiedor out of energy: no turn. - Percept.
perceivebuilds it (below). - Rule set.
rule_set_for(movement_type, helper, ally). - Decision.
think(&percept, set):- off the player’s view: the off-view rule (below), and the rule set is never asked;
- in view: the first rule that returns
Some, orAction::Wait.
- Action.
actcarries it out. AStrikeis a step onto the foe’s tile, so both pass the same checks (mob_can_enter): on the map, walkable (water only for a swimmer), a legal diagonal, and the room leash whenleashed(&percept, set)is true. A step onto a foe queues a blow onAttackQueue; onto anyone else, the mob stands. A pinned mob never steps.
Off the player’s view
A mob on a tile the player cannot see does nothing, with two exceptions:
| Mob | Off-view action |
|---|---|
carries Aggravated { tx, ty } | one step straight toward (tx, ty), never leashed; waits once there |
is the player’s Helper | the heel rule, never leashed |
Both switch to their rule set the moment they stand in view. “The player’s view” is the view with their eyes open: a blinded player is still seen by the room they stand in.
Percept
| Field | Type | What it holds |
|---|---|---|
at | Position | Where the mob stands. |
in_view | bool | The mob’s tile is in the player’s view. |
player_at | Position | Where the player stands. |
noticed | bool | In view, and within STEALTH_RANGE if the player is Stealthy. |
pinned | bool | Pinned, Rooted or Clamped: strikes, but never steps or shoots. |
swims | bool | Deep water is floor to it. |
launcher | bool | A launcher is drawn in its hand. |
spellset | Vec<SpellEffect> | Each spell in its Spellset that its Magic can pay for. |
roll | u32 | A die rolled for this turn off the seed’s GameRng, so a seeded run replays it. |
helper | bool | It is the player’s Helper. |
ally | bool | Its Faction is Ally: its spells never go where they would catch the player. |
aggravated | Option<Position> | Where an Aggravated mob is heading. |
foes | Vec<Sighting> | Everything it would come to blows with (ai::hostile) on a tile the player can see and not Hidden, nearest first, ties by tile. The player is in it only once noticed. |
map | &Map | The floor. |
Sighting is { who: Entity, at: Position, is_player: bool }.
Action
| Variant | Carried out by |
|---|---|
Strike(Entity) | A step onto the foe’s tile, which queues a blow. |
Shoot(Entity) | items::monster_ranged_attack. |
Cast(SpellEffect, Position) | items::apply_spell_effect(mob, at, spell, 1). Free: no Magic. |
Step(dx, dy) | One step; into a foe, a blow. |
Wait | Nothing. The turn is not spent. |
The rules
Each is a const Rule wrapping a plain function of the percept.
| Rule | Fires when | Action |
|---|---|---|
CAST | The spellset is not empty. It picks spellset[roll % len] and fires it at the nearest foe in the spell’s SpellDef::range. Only Force Lance needs a clear line; the rest are aimed at the foe’s tile. An ally passes over any foe whose footprint (agents::reaches) holds the player: a blast radius, the lance’s line, or the target tile itself for Sting and Thunderbolt. Only an Attack spell with a range above 0 is ever fired. Never at a foe across a corner (across_corner). | Cast |
SHOOT | A launcher is drawn, not pinned, a foe within MONSTER_SHOT_RANGE with a clear line, and not across a corner (across_corner: next to it, but past a doorway or room corner it could not step across). | Shoot the nearest such foe |
STRIKE | A foe on one of the eight tiles around it, bar one across a corner (across_corner). | Strike it, the player before anyone else |
CLOSE_IN | Any foe: the player once noticed, or an ally of theirs. | Step along the shortest walk to the nearest |
HEEL | More than one tile from the player. | Step along the shortest walk to a tile next to them |
FLEE | The player is among the foes. | Step straight away from them |
STAGGER | Always. | Step north, south, east or west by roll |
A clear line (agents::clear_line) is one no wall breaks; bodies in it do not count.
A spell’s footprint (agents::reaches): Dragon Breath covers items::blast_cells around its target (BLAST_RADIUS, line of sight from the centre), Lux the same at GRENADE_RADIUS, Meteor Strike at GRENADE_RADIUS plus METEOR_STRIKE_CHAIN_SPREAD (the first chain only, roughly); Sting and Thunderbolt cover their target tile; Force Lance covers its line up to the target, stopping at the first wall. A spell whose footprint is not listed there is assumed to reach everyone, so an ally never casts it.
The shortest walk is autoexplore::first_step over tiles the mob can stand on, biased toward the goal. A mob knows the floor it stands on, so it is not limited to tiles the player has seen.
The sets
| Set | Picked when | leashed | Rules, in order |
|---|---|---|---|
STILL | MovementType::Static | no | (none) |
CHASER | MovementType::Chase (and the retired Aggravated) | yes | CAST, SHOOT, STRIKE, CLOSE_IN |
ALLY | Chase on a Faction::Ally that is not a Helper (a charmed monster) | no | CAST, SHOOT, STRIKE, CLOSE_IN |
AMBUSHER | MovementType::Ambush | no | STRIKE |
FLEER | MovementType::Flee | no | FLEE |
STAGGERER | MovementType::Confused | no | STAGGER |
HELPER | the mob is a Helper, whatever its variant | no | CAST, SHOOT, STRIKE, CLOSE_IN, HEEL |
The room leash: from a Room tile, a leashed mob will not step onto a Passage or a Door. The leash belongs to the tile it stands on, so from a corridor it crosses a door freely.
A CHASER’s turn reads as: pick a spell at random, fire it if it can, else shoot if it can, else go to melee. A wild monster does not care whom its spell catches. A CHASER closes in on the nearest foe, so an ally of the player standing nearer than the player draws it away from them; STRIKE still hits the player first when both are alongside. A charmed monster’s ALLY set is the same turn aimed at the monsters, and it does nothing when none is in view. The HELPER’s is the same turn aimed at the monsters, with the player never in the blast, and a walk back to the player when there is nothing to fight.
Where the pieces live
| Piece | File |
|---|---|
Percept, Action, Rule, RuleSet, the rules, the sets, think, rule_set_for | models/src/agents.rs |
perceive, act, the gates, energy and rounds | models/src/ai.rs |
MonsterDef::casts (a species’ spells and Ma) | models/src/monsters.rs |
can_afford_spell, pay_for_spell | models/src/items/spells.rs |
Aggravated | models/src/components.rs |
blast_cells | models/src/items/wands.rs |
See also
../explanation/agents.md why a reflex agent, and why the view ../how-to/add-a-rule-set.md a new way to think ../how-to/add-a-rule.md a new reflex ../tutorial/give-a-monster-a-mind.md one set, start to finish
Reference: command line and environment
Audience Anyone running the game, especially to test content.
Prerequisites None.
Status Describes the flags as parsed in
`engine/src/main.rs`. If this page and the source
disagree, the source is right and this page is a bug.
cargo run -p nihilurk -- [flags] [name-or-save]
After cargo install nihilurk, run nihilurk [flags] [name-or-save] instead. If the shell cannot find it, ~/.cargo/bin is not on your PATH; the README’s install steps shows how to add it.
Flags
In any order. Every flag has a single-dash form; the shorthand ones (-s, -c, -ns, -nb, -nshake, -mouse, -nobones, -b, -am, -scores) also have a double-dash longhand (--seed, --centered, --no-save, --no-blood, --no-shake, --mouse, --no-bones, --body, --as-monster, --leaderboard), and -endless, -anim-rate, -keymap, -content, -pride and -prideoff also take --. Any other argument that starts with - is refused, and so is a flag whose value is missing or bad (-s -5, -s abc, -anim-rate fast, -b with nothing after it); the message quotes the flag and its value: invalid argument ARG. Please see -help for more information. A player name therefore cannot start with -.
| Flag | Effect |
|---|---|
-s <seed>, --seed | Start the run from a specific u64 seed. Reproducible. |
-c, --centered | Centre the map on the terminal instead of on top left. |
-ns, --no-save | No save. The run is never written to disk. |
-nb, --no-blood | No blood. Suppresses bloodstain rendering, and with it the flung-corpse-and-bones death animation — a kill just leaves a static grey corpse mark. |
-nshake, --no-shake | No screen shake. The map never leaves its moorings — nothing arms one for the rest of the run. For anyone who would rather the terminal held still; -anim-rate can only make a shake slower, which is the wrong direction. |
-mouse, --mouse | Turn on mouse capture. Left click reads a tile (look) and opens its Info popup; right click travels there, like O then Enter. On the aiming reticle, left moves the cursor and right moves it and fires; on the O cursor, left moves it and right goes. Menus and the --MORE-- prompt ignore clicks. Off by default, because capture takes the terminal’s own text selection (Shift+drag copies). |
-nobones, --no-bones | Skip the bones mechanic entirely: a death never writes a bones-N.sav, and an ascent never reads one. Not the corpse-fling animation -nb mentions above — this is the NetHack-style “a past run’s ghost, guarding its own cursed gear” (models::bones). |
-endless, --endless | No Element of Yoord ever spawns, so there is no way to win: the dungeon keeps going down past FINAL_DEPTH. Sets the Endless resource (models/src/map.rs), which the save records (SaveGame::endless); refused together with -fast; the depth-FINAL_DEPTH checks in traps.rs, monsters.rs and saveload.rs read it. |
-fast, --fast, --bullet, --quick (also -bullet, -quick) | A six-floor dungeon: the Element of Yoord sits on floor 6, and floors 4 and 5 are always special levels (Floors::forces_special). Sets the Floors resource (models/src/map.rs); floor n draws monsters, traps and items as depth ceil(n*13/6), and every floor runs its item budget twice (Floors::item_multiplier). The save records the run length (SaveGame::floors), so on a load the flag is ignored and the saved length wins; passing it with a save says so and asks Load anyway? [y/N] (same for -endless). Refused together with -endless. |
-nrec, --no-recording | Skip the recording. A new run otherwise writes recordings/NAME-SEED.rec in the data directory (an existing file of that name is replaced): a header (version, seed, name, body, -endless, -nobones, the NIHILURK_SPAWN/LEVEL/MAGICMAP values, the keymap) then one line per key press or click the game acted on, stamped with the input call it arrived on (engine/src/input.rs). Resumed runs are not recorded. |
-v FILE, --replay FILE | Play back a recording from its first turn. Sets -s, -b/-am, -endless, -fast from the header, forces -ns -nobones, ignores your keymap and shaping variables, writes no save, score, dump or bones, and ends with one line saying how the run finished. Warns on stderr when the recording came from another version, since a changed table can drift it. |
-content, --content | Print every name the content tables know, then exit. |
-scores, --leaderboard | Print the leaderboard (top 10 scores ever recorded), then exit. Add -fast or -endless for that mode’s board. |
-anim-rate <n>, --anim-rate <n> | Multiplier on every animation frame’s on-screen hold time (particles, the magic-mapping reveal wipe, the screen shake). 1.0 is the default pacing; raise it if a terminal’s redraw can’t keep up, lower it for snappier animations. Clamped to 0.1..=5.0; a bad, non-finite or missing value is refused as an invalid argument. |
-keymap <file>, --keymap | Remap keys from the file; without it, keymap.txt in the data directory is read if it exists. See Key remapping. A bad line or an unreadable file is named on stderr and the game does not start. |
-b <body>, --body | Play as nihil (the default) or lurk. See below. |
-am <species>, --as-monster | Play as a monster: any bestiary name (-am dragon). See below. |
-b <body>
Which of the two written-to-be-played creatures descends. -b nihil is the
default and changes nothing.
-b lurk is the other one: quadruped, fanged, clawed, furred, and a magenta
@. Lower stats and the only thing it can put on is rings. What it has instead:
SpeedKind::Quick, half again as fast asNormaland the only creature in the game born at that tempo. Two monster rounds bought per three player turns; shown asQUIKon the status line.- The estoc’s lunge (
Lunges), without the estoc’s double time. - The rapier’s momentum (
BuildsMomentum), built on the creature rather than on a blade it does not have. - A ring of stealth’s quiet (
Stealthy). - Bide in its spellset.
- Growth. Every creature that dies on the floor is rolled against tuning
constant
lurk::GROWTH_CHANCE, and a hit puts one point on one of the four numbers on the status line, drawn at random:FEAR THE LURK!Rolled per kill rather than counted toward a tenth one, so there is no counter to pace against and nothing for a save file to remember.
Its tempo is deliberately lumpy: two monster rounds per three player turns means the free turn always lands third, and pacing yourself against that beat is how lurk is played.
-am <species>
The run starts in that species’ body instead of nihil’s or lurk’s. The
name must be a bestiary row exactly as -content prints it;
anything else stops the game before it starts rather than quietly starting
you as nihil. -b and -am are the same choice asked two ways,
so passing both is refused too.
What the body changes is what the bestiary row says: the glyph and its colour,
the hit points, the attack and armour dice and their bonuses, the tempo (-am wraith is permanently hasted), invisibility (-am phantom), and the innate
magic – a dragon is immune to fire and flies, a rattlesnake’s bite is
venomous, a slime splits when hurt – into a hostile copy of you, carrying
your hit points, which is working as intended. None of that is a special case
for the player: the bestiary row is the same one a dragon on floor 10 is built
from, and the on-hit abilities are the same table.
A row with gear chances (.equip(...)) starts you with all of it, every roll
hit, worn if the body may wear it (ItemUser) and in the pack if not. A bow
comes with a full stack of arrows. Only the start of a run does this; a
polymorph hands out nothing.
What it does not change is who you are. You keep the @’s side of the fight,
your viewshed, your pack, your score and your magic points; ai never gets
hold of you.
Four consequences worth knowing before you pick a monster:
- You start with nothing. The ring mail, mace, short bow, quiver and potion are nihil’s kit.
- Only a species that uses items can wear them. That is the bestiary’s
own
ItemUsermark – the orc, hobgoblin, centaur, medusa, nymph, leprechaun, troll, vampire and ur-vile have hands; a dragon has claws and is told so when it tries. - A species’ spells land in your spellset, and cost your Ma. A dragon knows Fireball, the same spell a hero coin teaches, cast through the same reticle at the same price out of your own pool. The dragons on floor 10 breathe it too, from a small pool of their own that never refills.
- The log tells you what you were born with. One “You feel…” line
per grant the body carries, a spirit’s two random boons included:
“You feel venomous.” Tags the HUD already shows are not repeated.
Try
-am dragon. You start on floor 1 with Fireball in your spell bar and claws that hold no sword.
This will never be a balanced mode of play. A floor-1 kestral and a
floor-10 dragon are both one -am away, and nothing gates which one you’re
allowed to start as, or scales the dungeon to match your pick. That’s
deliberate: -am is a costume, not a difficulty setting, and every row in
the bestiary is tuned to be one thing a @ fights, never to be the @
fighting everything else. Play it for the thrills, not for a fair fight.
Bodies and species
Cancellation draws a line between the two, on purpose.
A body cannot be cancelled. It is the creature the run is about, and no wand in the dungeon is a wand of being something else: a lurk stays a lurk, keeps its shape, and keeps the rule about rings.
A species can, apart from any part its row marks as an identity effect (the dog’s five grants, see content-tables.md, “Identity effects”). A player wearing a bestiary row keeps the shape and the
dice – those are not effects at all – but the magic is only on loan: a
cancelled dragon-bodied player loses fire immunity exactly as a real dragon
would, and a cancelled orc-bodied one loses ItemUser, which is to say the
wits to work a buckle, and can no longer equip what it was wearing a moment
ago. That is the bargain of wearing something else’s magic.
The body is saved with the run and comes back on load – the player’s name is the species, and that is how the save knows. Two argument shapes are refused up front to keep that true, both before the terminal is touched:
nihilurk Dragon # "Don't name yourself a monster. It's quite demeaning."
nihilurk -am orc mysave # the save already knows what body it is in
nihilurk -b lurk Bae # a name is the first argument or it is not a name
A monster’s hit points are its own, so several rows are a one or two-hit death: that
is intended, and -s is how you retry it.
-h, -help, and --help print a short guide and exit before the terminal is configured. The full reference is nihilurk(6). The prebuilt tarball and the AUR package install it, so man nihilurk works there; cargo install installs the binary only, so from a clone read it with:
man ./doc/nihilurk.6
The game needs a terminal of at least 80 columns by 25 rows. In a smaller one the message log draws garbled letters. That is a known limit and will not be fixed.
-content never touches the alternate screen, so it pipes:
cargo run -p nihilurk -- -content | grep ring
cargo run -p nihilurk -- -content | less
-scores reads the same way, straight off leaderboard.sav in the data directory (-scores -fast reads leaderboard-fast.sav, -scores -endless reads leaderboard-endless.sav; each mode keeps its own board) (see NIHILURK_DATA below) – a small versioned postcard file, like a save, holding the ten highest scores ever recorded across every run that ended in a win or a death (a quit-and-save doesn’t count; the run isn’t over). Each line it prints is RANK. NAME - OUTCOME - SCORE (WHEN) followed by one ◆ per conduct that run kept (see conducts.md), where OUTCOME is WIN, LOSE (Asc.) (dead on the way back out, carrying the Element), or LOSE (Desc.) (dead on the way down), and WHEN is the UTC date and time the run ended. A version 1 leaderboard, from before conducts, is read and upgraded on the next write. A leaderboard it can’t read, damaged or from another version, is named on stderr and never overwritten: no score is recorded until it is moved away. Two runs ending at once take turns at the file through leaderboard.sav.lock.
A run that ends in a win or a death also leaves a run dump, morgue/NAME-YYYYMMDD-HHMMSS.txt in the data directory (UTC; any character of NAME that is not a letter, a digit, - or _ becomes _). It is plain text for sharing: the outcome, cause, depth and score, the final screen, the pack, every kill by name (most first), for a win the conducts kept, and the message log. The path is printed once the terminal is back. -ns does not stop it – a dump is a record of the run, like the leaderboard, not a save. A dump never overwrites another, and one that can’t be written is skipped without a word.
-s fixes the dungeon’s maps. Every floor’s walls are a pure function of (seed, depth), so floor 7 of seed 1234 is the same maze today, tomorrow, after you reload a save, and after somebody adds a monster to the bestiary. Its contents – monsters, loot, traps – are re-rolled each time you enter the floor (they key off the staircase count as well), so walking back up through floor 7 finds the same corridors freshly stocked. How you play still does not reach into generation: two runs on one seed that take the same staircases see the same everything.
The positional argument
One bare argument, meaning one of two things:
-
A save file, if it is the path of a file as typed, or names one in the data directory – as is or with
.savappended, matched case-insensitively. The run is loaded, and saves back to that same file. -
A player name, otherwise. A fresh run starts under that name, saved as
<name>.savin the data directory. A name holds at most 13 characters, no control characters (an escape, a tab, a newline) and is not a Windows device name (con,prn,aux,nul,com1-com9,lpt1-lpt9, in any case); anything else is refused with a message and nothing is written. In file names (the save,recordings/,morgue/) every character that is not a letter, digit,-or_becomes_, soa bsaves asa_b.savand../xcannot leave the data directory.cargo run -p nihilurk -- nihilurk # loads nihilurk.sav from the data directory if it is there cargo run -p nihilurk -- ./old.sav # loads that file, wherever it is cargo run -p nihilurk -- bae # otherwise: a new run as bae
If the save is clear data – a won run, which is kept rather than deleted – the game asks before spending it.
Language
The language is a build-time choice, not a flag. engine forwards one lang-* feature (en by default, pt, es, ht) to strings, models and view, and the binary carries that language only:
cargo run -p nihilurk --no-default-features --features lang-pt
The installed nihilurk command is nihilurk-dispatch, which reads LC_ALL, LANG or LANGUAGE, and execs the matching nihilurk-<lang> beside it, passing every argument through. --lang <en|pt|es|ht> as the first two arguments overrides that and is stripped before forwarding. Anything it does not recognise gets English.
A cargo install has no dispatcher and no --lang: it holds the one language it was built with. Reinstall with cargo install nihilurk --force --no-default-features --features lang-pt to switch. The prebuilt download holds all four.
Environment variables
NIHILURK_SPAWN
Comma-separated content names, dropped on free tiles around the player the moment a floor is built. The content author’s shortcut: it means you never have to play down to floor 9 to look at a floor-9 monster.
NIHILURK_SPAWN="dragon" cargo run -p nihilurk
NIHILURK_SPAWN="bow,arrow,ring of protection" cargo run -p nihilurk
NIHILURK_SPAWN="dart trap, long sword" cargo run -p nihilurk
NIHILURK_SPAWN="cursed -2 long sword,+3 ring mail,arrow x13" cargo run -p nihilurk
Details:
-
Any name from
-contentworks – monsters, items, traps, the Element itself. -
Whitespace around each name is trimmed; empty entries are skipped.
-
Gear and ammunition take optional dressing, written around the name in this order:
[cursed|megacursed] [+N|-N] name [xN].Piece Example Effect cursedcursed ring mailAdds the Cursetag: once worn or wielded it will not come off until a scroll of remove curse.megacursedmegacursed ring mailAdds CurseandMegaCurse: nothing lifts it or destroys it.+N/-N+3 long swordAdds N to the item’s flat bonus: a weapon’s hit roll, armour’s guard, or a launcher’s throw (the arrows it looses). On a numeric ring (protection, strength, increase damage, sharpshooting) N is the ring’s whole number, so +3 ring of protectionis a +3 ring. Same code as the dungeon’s own enchantment roll (catalog::apply_bonus).xNarrow x13Sets the stack size, clamped to 1..=STACK_LIMIT.x0gives 1, and anything over the limit gives the limit.A curse and a minus are independent:
-2 long swordis not cursed, andcursed +2 long swordis. The quality is hidden until worn or identified, as for any gear. -
A modifier the thing cannot use (a plus on a ring that is not a number, a stack on a dragon) is ignored, and the entry still counts as recognised.
-
A malformed modifier (
+x sword) is not a modifier: it stays part of the name, so the entry is skipped like any unknown name. -
A name the tables do not know is skipped silently. This is a debug knob, not a parser. Check your spelling against
-content. -
Things are placed on the nearest free walkable tiles, searching outward in rings from the player. Nothing lands in a wall or on top of anything else.
-
It applies to every floor, not just the first. Descend and your dragon is waiting again.
-
Items arrive exactly as their row describes them – unenchanted, uncursed, a single arrow rather than a bundle – unless you dress them as above. For the randomised version, find one on the floor.
NIHILURK_LEVEL
Makes every floor one kind of special level instead of rolling for it. Most special levels turn up at their own small chance (constants::map), from SPECIAL_LEVEL_MIN_DEPTH down; this is how you look at one without hunting for a seed that has it.
NIHILURK_LEVEL=island cargo run -p nihilurk
| Value | Level |
|---|---|
battlefield | Battlefield |
labyrinth | Labyrinth |
vault | Vault |
bee, beeworld, bee world | Bee World |
castle | Castle |
island | Island |
Details:
- Case does not matter. A value that names no level is ignored, and floors roll as usual.
- It applies to every floor, the first and the last included – floors the roll itself never touches.
- A save rebuilds its map from the seed on load, and this is read then too. Load a save with the same value it was written with, or the floor comes back a different shape under everything standing on it.
NIHILURK_MAGICMAP
Forces the animation a scroll of magic mapping plays, instead of rolling one. Useful when you are working on the animation itself.
| Value | Style |
|---|---|
row, rows, rowbyrow, row-by-row, curtain | Row by row |
spiral, swirl | Spiral |
explode, explosion, blast, burst | Explode |
Anything unrecognised falls back to a random roll.
Key remapping
keymap.txt is one pair per line, two characters with no separator (Brogue’s format): hj makes pressing h act as j. List hj and jh to swap; lookup is a single pass, so pairs never chain. A key with no line acts as itself, so hl alone leaves l live too. An uppercase letter with no line of its own follows its lowercase one, so Shift-run keeps working. Blank lines and lines starting # are skipped. A line of any other length, a space as a key, or a key listed twice is refused with its line number (models::parse_keymap).
The file is read from the data directory, or from the path given to -keymap. A missing auto-loaded file is fine; a missing -keymap file is not. F1 and the sidebar hints show the key that now does each job (word keys such as Tab stay as they are). A command whose own letter is remapped away and that nothing maps to shows its old letter.
NIHILURK_READY
Set and not empty, the game writes an OSC string, ESC ] nihilurk;ready BEL, each time it blocks waiting for a key: the turn loop, the O travel cursor, and the death and victory screens. A script that drives the game through a pty reads it as “the last key has done all it will”: the play agent’s serve.sh sets it and answers a key the moment it sees the signal, instead of waiting for 250 ms without output (engine::ready).
NIHILURK_READY=1 cargo run -p nihilurk
Details:
- It comes after the frame it settles, on the same stream, so a reader that has the signal has the frame.
- Nothing is written before a read that only lets a key skip an animation or stop a walk, or while a key is already waiting to be read: the game is not waiting there.
- A terminal ignores an OSC string it does not know, so the game looks the same with it set. Unset, the game does not write it or look at the input for it.
NIHILURK_DATA
The directory nihilurk keeps its files in: saves, bones-N.sav, leaderboard.sav, leaderboard-fast.sav, leaderboard-endless.sav and the run dumps in morgue/. Set, it wins. Unset or empty, the OS convention decides (models::data_dir):
| OS | Data directory |
|---|---|
| Linux, the BSDs | $XDG_DATA_HOME/nihilurk, or ~/.local/share/nihilurk |
| macOS | ~/Library/Application Support/nihilurk |
| Windows | %APPDATA%\nihilurk |
-
A relative
XDG_DATA_HOMEis ignored, as the XDG spec says. -
With no home to go on at all, it is the current directory.
-
The directory is made on the first write, not before.
NIHILURK_DATA=. cargo run -p nihilurk # keep everything in the checkout, the old way
Recipes
Look at a new monster immediately:
NIHILURK_SPAWN="basilisk" cargo run -p nihilurk
Reproduce a run someone reported:
cargo run -p nihilurk -- -s 1234567 -ns
Walk around an island from turn one:
NIHILURK_LEVEL=island cargo run -p nihilurk
Check a name before you use it:
cargo run -p nihilurk -- -content | grep -i staff
Test a throw build from turn one:
NIHILURK_SPAWN="short bow,arrow,arrow,ring of sharpshooting" cargo run -p nihilurk
See also
spawn-api.md spawn_named, spawn_requested
conducts.md the conducts behind the marks on -scores
content-tables.md what the names refer to
input-and-turn-loop.md where these flags are read (engine/src/main.rs)
rendering.md -c, -anim-rate and -nshake in the renderer
../tutorial/add-your-first-item.md these tools in context
Reference: components, resources and events
Audience Engine developers. Look a type up here; do not read it
end to end.
Prerequisites A passing familiarity with `bevy_ecs`: components are
per-entity data, resources are singletons, events here
travel by queue.
Status Describes `models/src/components.rs` as it is in the
source. If this page and the source disagree, the
source is right and this page is a bug.
Almost every Component, Resource and Event is declared in models/src/components.rs. That file is nouns only — a component says what a thing is, never what happens as a result. The verbs are the systems: combat.rs, ai.rs, items/, visibility.rs, traps.rs.
The exceptions are the handful of resources that come with real logic attached and live with it instead: Shake (shake.rs), AutoExplore / AutoPickup / LeftBehind (autoexplore.rs), FastMove (fastmove.rs), and the pack screen’s PackIsOpen / PackMode / ItemAction (pack.rs). All of them are re-exported from models, so a call site never has to know which file they came from.
A Bundle — the struct that assembles components for one spawn (MonsterBundle, TrapBundle) — is not here. It lives next to its content table (monsters.rs, traps.rs), because it names that table’s Def row and is the one place an entity of that kind is described. See ../explanation/data-driven-content.md, “Where the pieces live”.
Two shapes recur:
- Marker — no fields. Presence is the fact (
Player,Blood,Curse,Confused). - Type key — one enum naming which one it is (
Potion→PotionEffect). The key is identity foridentify.rsand the save file; it is never behaviour. The catalog row turns a key into the components that do something; the mechanic is amatchon the key.
“Saved?” below means the field survives models/src/saveload.rs. A transient field is rebuilt every frame or every load and deliberately left out of the save. A catalog component is re-attached from the row’s id on load (restore_from_catalog), so it is never written either.
Components — identity, position, appearance
| Component | Data | On | Saved? |
|---|---|---|---|
Name | what: String | monsters, items, hero | yes |
Player | marker | the hero | yes |
Position | x, y: u16 | anything on the floor; removed while in a Backpack | yes |
Renderable | glyph: char, color: Color | anything drawn | yes (colour packs to one byte against a 16-entry palette) |
Faction | enum Player / Monster / Ally / Spirits / Inert | every actor | yes |
Name::article() returns "a" / "an" for the name.
Components — what the player is
Body (models/src/body.rs, not components.rs — it is the one type this
whole page’s “nouns only” file doesn’t hold, because it is read at
character-build time, not by a system) is which of three shapes the run’s
hero descends in: Nihil (default), Lurk, or Monster(&'static MonsterDef) — a bestiary row worn as a costume, -am <species>. One enum
rather than three flags, because a player is exactly one of them.
| Type | Data | On | Saved? |
|---|---|---|---|
StartingBody | Resource, Body | which body the next spawned player wakes up in; read once by map::initialize_world | not saved directly — see below |
MonsterBody | Component, &'static MonsterDef | the player, only when wearing a species | yes, as the species id (SaveGame::monster_body), not read back from Name |
Lurk | effect marker (crate::effects) | the player, only as a lurk | yes — an ordinary row in EFFECTS, held like anything else a creature was born with |
A monster body is the one body the save writes down: SaveGame::monster_body
holds the species id, and load_game puts MonsterBody and StartingBody
back from it. It is not read back from Name, which is whatever the player
typed. A lurk is reconstructed from the effect ledger, and nihil is what is
left.
Body::equip_refusal is the one gate on whether a body can wear something,
and Body::innate_tempo is the tempo it returns to when a staircase lifts
whatever the floor lent it — both take &World and an Entity rather than
reading Renderable’s glyph back out, on purpose: nothing else in the game
guesses what a creature is from how it’s drawn.
See ../how-to/add-a-body.md for adding a fourth one.
Components — creatures and combat
| Component | Data | On | Saved? |
|---|---|---|---|
Mob | movement_type: MovementType | every monster | yes |
Fighter | hp, max_hp, armor, power, max_power, armor_bonus, power_bonus: i32 | every actor | yes |
Blood | marker | creatures that bleed | transient — re-attached to the player and every mob on load |
Magic | points, max_points: u8 | the hero | yes |
IceCube | marker — a frozen corpse (ice.rs): a Mob with no Fighter, Faction::Inert, glyph # in cyan, so it blocks like a creature and nothing can hurt it; the player walking into it calls kick_ice_cube; a missile or blast that stops on it sets off detonate_ice_cube, a standard trick shot of Element::Cold; so does any blast over it, and a fire blast (scorch_ice_cube) doubles the radius and damage; a player killed by one is recorded as “Killed by an ice cube” | one per cold kill | yes (ice_cube field) |
ColdSlain | marker — set by apply_hit when a Cold hit leaves a creature at 0 HP; finish_indirect_kill then freezes it into an IceCube instead of a corpse (not a Helper) | transient | no |
Helper | marker — the player’s boon companion, a Faction::Ally that follows them between floors | at most one ordinary one, plus any number with PriorityHelper | yes (its own field, not an effect row) |
| Mimic | marker — a xeroc still in disguise: it reads as an item and every “monster nearby” check skips it. reveal_mimics strips it when the player stands adjacent | xerocs | no |
| Spellset | slots: Vec<SpellEffect> — the spells the creature knows, capped at constants::spells::SPELLSET_CAP | the hero | yes |
| GhostOfPlayer | marker — the bones ghost of the player’s own past run; combat.rs narrates a fight with it as “you” against yourself | the bones ghost | no |
MovementType — enum, saved by variant order:
Each variant picks the rule set the monster thinks with; agents.md has what each set does.
| Variant | Rule set |
|---|---|
Static | STILL — never acts. |
Chase | CHASER |
Flee | FLEER |
Confused | STAGGERER (the monster’s confusion) |
Aggravated { tx, ty } | Retired, kept for its position. A save that carries it loads as Chase plus the Aggravated component. Never set it. |
Ambush | AMBUSHER |
A Helper thinks with HELPER whatever its variant. A charmed Chase monster (a Faction::Ally without Helper) thinks with ALLY.
| Component | Data | On | Saved? |
|---|---|---|---|
Aggravated | tx, ty: u16 — where the noise came from; out of the player’s view the monster beelines there | every monster on the floor, after a scroll of aggravate monsters or a ring’s shriek | yes |
Combat: damage = (1d[power] + power_bonus) - (1d[armor] + armor_bonus), both sides rolled independently, nothing ever misses. power is dropped by a poisoned dart trap — one point per depth tier — and healed back toward max_power.
Components — speed and tempo
The player is the clock. Monsters bank energy on each player turn and spend it in ai.rs; the player’s own tempo is run by the engine loop through the PlayerTempo resource.
| Component | Data | On | Saved? |
|---|---|---|---|
Speed | kind: SpeedKind, energy: i32 | every actor | kind yes; energy transient (resets to 0) |
SpeedKind — enum, saved by variant order (Slow, Normal, Fast, Quick — Quick last on purpose; Normal is #[default]):
| Method | Returns |
|---|---|
rate() | energy banked per player turn, against Speed::COST. |
faster() | one step round the haste cycle: Normal → Fast → Slow → Normal (haste monster). |
slower() | one notch down, Slow the floor (slow monster). |
Speed::COST — the energy one action costs.
Components — perception and memory
| Component | Data | On | Saved? |
|---|---|---|---|
Viewshed | visible_tiles: Vec<(u16,u16)>, revealed_tiles: FixedBitSet, range: u16, dirty: bool | the hero (and anything that needs sight) | revealed_tiles + range yes; visible_tiles + dirty transient |
Hidden | marker — not drawn or announced right now | out-of-view monsters, undiscovered traps, unperceived invisibles | transient (visibility rebuilds it; traps clear it on reveal) |
Invisible | marker — intrinsically unseeable without SeesInvisible | the phantom, and the invisibly-stashed floor item a floor hides at population::HIDDEN_ITEM_CHANCE | yes |
Spotted | marker — inside the player’s viewshed this turn | anything currently seen | transient |
Detected | marker — drawn on the map even out of view, in DarkMagenta | whatever a potion of magic / monster detection turned up | yes |
A detection that finds a stashed item strips its Hidden and Invisible too, the way a ring of perception does — Detected only paints tiles the player cannot see, so a stash left hidden would glow from across the floor and vanish on arrival.
revealed_tiles is indexed by crate::map::tile_index.
Detected is an effect held for Lifetime::Floor, and leaving the floor despawns every entity that carries one anyway. It says where, not what is happening there — a detected monster is never announced and never animates.
Components — items on the floor and in the pack
| Component | Data | On | Saved? |
|---|---|---|---|
Item | marker — can be picked up | every item | yes |
Value | amount: i32 — score, paid the moment it is picked up | treasure coins, the relic | yes |
Pickup | effect: PickupEffect, amount: i32 — works where it lies and is gone; never carried | every coin | yes |
Backpack | items: Vec<Entity> — inventory order | actors that carry | yes |
Consume | marker — used up on use | potions, scrolls | yes |
Battery | charges: i8 | wands | yes |
Stack | count: u8 — how many share one slot | ammunition, treats | yes |
Treat | for_item_users: bool — thrown as an offer of loyalty, never used | snack, fancy of peace | rebuilt from the row on load |
Ranged | range: i32 — feeds the zap reticle | wands | yes |
Amulet | marker — the Element of Yoord; carrying it inverts the staircases | the relic | yes |
Stack tops back up to STACK_LIMIT on pickup. Backpack holds at most PACK_CAPACITY slots; stow refuses anything past that (“Your pack is full.”) and leaves it on the floor. Both are constants::items.
items::pick_up is the only way anything leaves the floor. The input handler calls it and knows none of what follows: the invisible-stash reveal, Value into the score, a Pickup spent where it lies, or stow. None back means the item is still there.
A Pickup is a different kind of thing from an item you stow, in three ways that all follow from “never carried”:
- A full pack is no obstacle. There is nothing to find room for.
- It is left alone when it would do nothing.
items::would_helpgates it — a red coin at full health stays on the floor, andautoexplore::known_item_tilesskips it so a walk never beelines for something it will refuse. It keeps until the day it helps. - It can be shot — and the shooter gets the effect across the room, plus a burst twice a trap’s width. A shot that stops on a creature standing on one counts, and the renderer advertises that with a magenta cell. See
content-tables.md, “Trick shots”.
PickupEffect: Coin, Health, Power, Cleanse, Strength, Platinum, Forge — saved by variant order, mechanic in models/src/items/pickups.rs.
Components — item type keys
| Component | Key enum | Mechanic | Saved? |
|---|---|---|---|
Potion | PotionEffect | items/potions.rs | key yes |
Scroll | ScrollEffect | items/scrolls.rs | key yes |
Wand | WandEffect | items/wands.rs; thrown, items/throwing.rs | key yes |
Ring | RingEffect | numbers + Grants from the RingDef row; the three with verbs are items/rings.rs | key yes |
Rune | RuneEffect | items/runes.rs; charged: bool says whether it still holds a use | key and charged yes |
Curse | marker | equipped-and-stuck until remove curse destroys it, or a scroll of enchantment burns it off | yes |
MegaCurse | marker | rides with Curse on a MEGACURSED item; nothing lifts it or destroys the item but a suit taking the killing blow | yes |
Vorpal | bane: String | any blooding hit slays bane (or any VorpalTarget) outright | yes |
KnownQuality | marker | this instance’s enchantment plus/curse/vorpal bane are known — set by wearing it (equipment::toggle_equipped/equip_silently) or a scroll of identify singling it out | yes |
All five key enums are saved by variant order — append, never reorder. The catalog tables (crate::catalog) are the other thing keyed off these enums.
KnownQuality is per-instance: two rings of protection are two separate rolls of the curse dice, so each needs its own KnownQuality, set the moment it’s worn (equipment::toggle_equipped/equip_silently) or a scroll of identify singles it out. identify::display_name reads it to decide whether to print a weapon/armour/launcher’s +N prefix, a (cursed) suffix, or a (vorpal vs. X) suffix — hidden for anything not yet known. Potions, scrolls, wands and rings carry no such hidden state of their own; they always show their true name.
WandEffect::needs_target() is false only for the wand of light (it floods the room, no reticle).
PotionEffect: Blindness, Confusion, ExtraHealing, FruitJuice, GainStrength, Haste, Healing, MagicDetection, MonsterDetection, Paralysis, Poison, RaiseLevel, RestoreStrength, SeeInvisible, Water, Magic, Adjustment, Polymorph.
ScrollEffect (every arm wired; BlankPaper does nothing on purpose): MonsterConfusion, MagicMapping, HoldMonster, Sleep, EnchantArmor, Identify, ScareMonster, FoodDetection, Teleportation, EnchantWeapon, CreateMonster, RemoveCurse, AggravateMonsters, BlankPaper, VorpalizeWeapon, Amnesia, Charming, Pitfall, Atonement.
WandEffect: Light, Striking, Lightning, Fire, Cold, Polymorph, MagicMissile, HasteMonster, SlowMonster, DrainLife, Nothing, TeleportAway, TeleportTo, Cancellation, Charming, Digging, Swapping.
RingEffect: Protection, Strength, Perception, Adornment, AggravateMonster, Sharpshooting, IncreaseDamage, Regeneration, SlowDigestion, Teleportation, Stealth, MaintainArmor, Polymorph, SustainForm, DualZap, Health.
RuneEffect (every arm wired; Blank does nothing on purpose and has no RUNES row): Blank, Recharging, Displacement, Justice, Chaos, Ice, Protection.
A Rune is a scroll that goes inert when read: reading spends charged instead of destroying the item, and items::recharge_runes wakes every non-blank rune in the pack on a staircase. A trapdoor does not. Only the player invokes one.
Element — not a component
Element (Fire / Cold / Drain) is a plain enum, not a component — it never lives on an entity. It sits in components.rs because both the wand mechanic and the throwing mechanic use it.
| Method | Returns |
|---|---|
Element::of(wand) | the element a WandEffect carries, or None. |
.immunity() | the Grant that shrugs it off (FireImmune / ColdImmune / Undead). |
.noun() | “flames” / “cold” / “evil magic” for the log. |
Components — throwing and launchers
A missile and a launcher never name each other; they meet at an effect (FireArrow / FireQuarrel). Resolution is items/throwing.rs.
| Component | Data | Meaning | Saved? |
|---|---|---|---|
ThrownDamage | i32 | die rolled on impact; no component ⇒ bounces off harmlessly | catalog |
LaunchedDamage | i32 | die rolled instead, once LaunchedBy fires | catalog |
Projectile | marker | spent on what it hits, never caught | catalog |
Piercing | marker | runs the whole aimed line, hitting everyone in it | catalog |
Returns | marker | thrown, flies home after the strike: into the pack row it left (WantsToThrow.slot_idx), and back in hand if it was wielded; f, v and Tab throw a wielded one | catalog |
ChainHits | u8 | thrown by the player, strikes this many creatures in all, each for full damage: the first in its way, then the nearest other living enemy each time (the thrower’s sight for the player, the blade’s own for a monster), before flying home | catalog |
LaunchedBy | Grant | the effect a launcher must grant to switch this missile to its LaunchedDamage die | catalog |
Launcher | marker | a bow / crossbow / blowgun — no attack die, enchant lands on ThrowBonus | catalog |
“catalog” = re-attached by row id on load (restore_from_catalog), never written to the save.
Reach weapons (combat::resolve_reach_attack, aimed with v) are the melee counterpart:
| Component | Data | Meaning | Saved? |
|---|---|---|---|
Reach | i32 | strikes this many tiles out instead of adjacent (bardiche 2, whip 5) | catalog |
ReachPiercing | marker | the strike runs the whole line instead of stopping at the first body (bardiche yes, whip no) | catalog |
Components — player conditions
| Component | Data | Meaning | Saved? |
|---|---|---|---|
Confused | marker | player-only stumble (a monster uses MovementType::Confused); blocks fast-move / auto-explore / auto-fight; HUD CONF | yes |
Blind | marker | player-only: viewshed cut to the 3x3, every glyph in it painted white, every mob Hidden (so auto-walk and auto-fight stall too). The AI is unaffected — see below. HUD BLND | yes |
Paralyzed | marker | for the player a PARALYSIS_LOST_TURN_CHANCE share of turns forfeited outright before a key is read (never over an open menu), tempo untouched. A monster has Speed dropped to Slow instead, and carries it for the renderer’s tint too, plus a log line of its own if the player can actually see it land (conditions::paralyse). HUD PARL | yes |
ConfusingTouch | marker | hands charged by a scroll of monster confusion: the next blow the bearer lands confuses what it hits and is spent doing it (an ABILITIES row keyed on Moment::OnHit — see content-tables.md). Not an impairment, and it survives a staircase. HUD GLOW | yes |
Plated | count | the platinum coin’s promise: reach the next staircase unhurt and it pays a permanent point of attack or defence die, the dungeon’s coin flip. stacks to 3, one payout per coin, one blow takes all. HUD PLxN | yes |
Forged | count | the forge coin’s promise: the same terms, paying a point of plus on the wielded weapon, worn armour or a worn ring with a number to it (the Princess of Diamonds’ pool), one of the three at random. stacks likewise. HUD FOxN | yes |
MagicWard | marker | the spell Magic Ward: for the rest of the floor, checked in helpers::apply_hit (every Hit with magical set – zapped, thrown, breathed or cast – bounces off with a cosmetic ricochet, wands::ward_ricochet) and abilities::fire_on_hit (nothing a monster’s landed blow carries with it takes hold). Lifted like Confused/Blind/Paralyzed above. HUD WARD | yes |
Bided | marker | the spell Bide: combat::fold_matchup folds constants::combat::BIDE_ATTACK_BONUS into the bearer’s very next attack roll, and combat::resolve_attack removes the marker the instant that roll is folded — hit, glancing or miss. Anything else done with a turn instead (a step, a used/thrown item, another spell) spends it unfired, via equipment::reset_momentum. HUD BIDE | yes |
Plated and Forged are the only conditions a staircase does not lift — the staircase is what settles them (items::settle_promises, and only for LevelChange::Stairs: a trapdoor is falling, not arriving). Any damage at all takes them back, through helpers::took_damage, which is also where the low-HP warning lives: the two things that happen to a creature because it was hurt, in one place, called from both damage paths.
Confused, Blind, Paralyzed and Speed haste/slow are treacherous — they never wear off with time. Only a staircase or a wand of cancellation clears them, all through crate::conditions::clear_player_conditions.
The first three are effects, not components of their own: rows in crate::effects’s EFFECTS, held for Lifetime::Floor, and listed once in conditions::AFFLICTIONS with the words for lifting each. That one table is what afflicted, cure_one_condition and clear_player_conditions all read. The table’s order is worst-first, because a cure takes the first row it finds. Speed haste/slow is not a row and cannot be: a tempo is a value, not a marker something either has or has not.
The verbs that put them on — confuse, blind, paralyse, hasten, shift_entity_speed, snare — live in crate::conditions, one per affliction, and each one already knows the difference between the player and a monster. snare is the exception to the “never wears off” rule above: it is counted in turns from the moment it lands, and it logs nothing, because the sentence belongs to whatever pinned you. A blinded monster has no viewshed to put out, so it gets MovementType::Confused; a paralysed one loses a share of its turns, the same coin flip the player makes.
A creature carries at most three conditions. A fourth is refused, in effects::lend — the one gate every transient effect already passes through, so a trap, a potion and a monster’s touch are all capped by the same line. lend returns false and attaches nothing; the verbs that print a success line (blind, confuse, paralyse, bide, magic ward) check it and stop. What the creature already holds stays, so nothing has to be undone.
What counts is Held::is_condition: transient and named by conditions::is_condition, which reads the lists that already declare conditions — AFFLICTIONS, FLOOR_BOONS, effects::HOLDS, and OTHER_CONDITIONS for the four with no list of their own. Both halves have to hold, and the default is exemption. A ring of regeneration lends Lifetime::WhileEquipped and fails the first half; a potion of magic detection’s Detected mark is transient but is nothing the marked creature feels, and fails the second. Neither may crowd a real condition out.
Refusing is never silent. The player reads “You already have too many conditions.” (strings::too_many_conditions). A monster’s refusal is silent, the same rule tick_effects keeps.
The priority badges
Speed haste/slow, Plated and Forged are priority badges: they are outside the ledger, so they are neither counted against the ceiling nor ever refused for a fourth condition. That is deliberate, not an oversight of the cap.
They earn it by being things the player cannot act correctly without. A tempo changes what every single step costs, and it is a value on Speed rather than a marker something either has or has not — there is nothing to shed. The two coin promises are standing bets that any damage at all cancels (helpers::took_damage) and a staircase settles (items::settle_promises); a promise silently displaced by a fourth condition would be a bet the player is still playing around and can no longer see. So they always show.
The bill for that lands on the line’s width. Three conditions, a tempo, a ring’s STLH, both promises and an auto-walk badge is a badge run long enough to reach the centred DEPTH and paint over it. That is accepted. Nothing panics — Screen::puts no-ops past the frame edge — and a player wearing that much at once did it to themselves, one potion and one coin at a time. Correcting it would mean either dropping a badge the player needs or making the line’s three anchors depend on each other again, and the whole point of splitting the HUD in two was that they do not.
Blindness does not blind the dungeon. crate::ai recomputes the view the player would have (visibility::visible_from(map, pos, false)) when they are Blind, so the monsters in the room still know exactly where they are. Drinking one is never a way to hide.
Components — traps and snares
Defined in components.rs (nouns); the mechanics are traps.rs.
| Component | Data | Meaning | Saved? |
|---|---|---|---|
Trap | effect: TrapEffect, reveal: TrapReveal, revealed: bool | a ^ entity; revealed latches once known | yes |
EntityMoved | marker | changed Position this turn — trap_system checks its tile | transient (cleared each trap_system run) |
TrapEffect — enum, saved by variant order (Trapdoor, Bear, Sleep, Teleport, Arrow, Dart). Keys the mechanic in apply_trap_effect; the catalog row (TrapDef) is name / glyph / rarity / snare_turns. Arrow and dart damage scale with depth — constants::traps.
TrapReveal — enum, saved by variant order (Sight, Adjacent, Triggered). Rolled equal-odds at spawn; read by visibility.rs.
The three holds — Asleep, Pinned, Rooted — are effects rather than components of their own, held for Lifetime::Turns and aged by effects::tick_effects. Asleep forfeits the turn outright (player_incapacitated); Pinned blocks movement only — a swing still lands, a step is a bloody thrash (bear_trap_thrash). Rooted is Pinned without the teeth: still biting, no thrash damage — a scroll of hold monster’s doing, and the one no trap lays. ai.rs applies the same rules to held monsters. Each runs on its own clock, so a creature can carry more than one.
Petrified is a fourth hold and the odd one out. A medusa’s gaze puts it on, it forfeits the victim’s turn exactly as Asleep does, and it protects: effects::stone_chip caps every hit at constants::combat::CHIP_DAMAGE and never lets one take the victim’s last point, on both damage paths (combat::resolve_attack and helpers::apply_hit), so the blow is reported as a chip rather than as a wound. Its one exception is a war hammer, which lends its wielder ShattersStone and goes through whole. It is deliberately not one of the HOLDS the two teleports let go of: those are things holding a creature in a place, and stone travels with the victim.
Components — score
| Component | Data | On | Saved? |
|---|---|---|---|
Score | value: i64 | the hero | yes |
Every change to it goes through models/src/score.rs, which is the whole scoring table in one screen:
| What | Worth | Where |
|---|---|---|
| A creature dies | KILL_PER_MAX_HP × its max_hp | combat::pay_for_the_corpse, from both the melee path and finish_indirect_kill |
| More than one dies in a turn | the turn’s kills together, ×(1 + COMBO_BONUS_PER_KILL per corpse past the first) | score::Combo, settled by score_turn_system |
| A staircase is used | STAIR_PER_TIER × (difficulty tier + 1) | map::levels::award_stair_score |
| A ring of adornment goes on | doubled | items::rings::wear_adornment |
| Treasure picked up | its Value | items::pick_up → score::award |
| The run is won | doubled | map::levels::win_with_style |
Deaths are paid for without asking whose blade it was: half the ways a monster dies have no attacker entity to ask about. Only a staircase pays — a trapdoor, the Dungeon Lord’s portal and a potion of raise level all move you between floors for free. Treasure pays as it is taken, not at the end of the run: a gold coin’s amount, and the relic’s 25000 the moment it is in hand — and the Value comes off with the payment, so however the relic comes to hand again (it cannot be dropped, but deep water throws it back) it never pays twice.
Kills are not paid one at a time. award_kill only files the corpse under Combo, the turn’s pile; score_turn_system — dead last in the schedule, after everything that can kill — totals it with the combo multiplier, pays it in one go, and empties the pile. A multiplier applied to a number that is still growing is not one anybody can read, and one turn’s killing never combos into the next. score::double settles the pile first, so a run that ends on the same turn as a kill doubles a score that already counts it.
i64, and every write to it saturates. Nothing caps how many rings of adornment a dungeon hands out and every one of them doubles the score, so the arithmetic has to have a ceiling it stops at rather than one it wraps past — a score is always a multiple of a hundred, and a multiple of a hundred that wraps an integer lands on exactly zero.
Every payment lights the ScoreFlash resource, which the HUD shows in the scorekeeper’s place for exactly one frame (armed during the turn, aged at the same tail, dark by the next): +700 in a random bright colour, COMBO! +2400 with the word in the stripes pride::stripes(world) returns, or DOUBLE in the same. A combo also writes one log line — “With style.”, or COMBO_PRIDE_CHANCE of the time “With pride.” All of its rolls are off FxRng, never GameRng: decoration does not get to move the gameplay dice.
Events and their queues
An input handler or the AI pushes an intent onto a queue resource; the matching system drains the queue once per turn.
| Event / queue | Fields | Drained by |
|---|---|---|
WantsToAttack → AttackQueue | attacker, target | combat_system |
WantsToUse → UseQueue | user, item, target: Option<Position>, slot_idx: Option<usize> | item_system |
WantsToThrow → ThrowQueue | thrower, item, target: Position, slot_idx: Option<usize> | throw_system |
WantsToCast → SpellQueue | user, effect: SpellEffect, target: Position | spell_system |
PlayerAction → PlayerActionQueue | Step(StepPlan) (a step, blow, lunge, kick or struggle), Stairs, Drop, WilledTeleport, Charge, Reach, each decided at the key | player_action_system |
All five queues are transient (empty at save time).
Resources
UI and input state — all transient, Default on load
| Resource | Fields | Notes |
|---|---|---|
HelpMenu | open: bool | the F1 key list. Any key closes it and does nothing else. |
ItemInfo | open: bool, lines: Vec<String> | the Info popup: the pack’s Info row, and ? or a click in look mode. Any key closes it and does nothing else. |
CommandBar | hidden: bool | F2 hides the yellow command bar; the log and player line move left into its columns. |
QuitPrompt | open: bool, warned: bool | the “Really quit?” modal, raised by Esc with nothing else open and answered y (or strings::quit_yes_key) / n. With models::time_stopped, the first yes sets warned (red “did not save” rows, “REALLY sure?”) and a second yes quits. Ctrl+C bypasses it. |
PackIsOpen | open, mode: PackMode, selected, action_mode: Option<usize>, action_selected | pack modal cursors; selected and action_mode are backpack indices, not row numbers. pack.rs. |
PackMode | enum: Browse Use Throw Drop Equip Quaff Read Zap Wield Wear PutOn | which key opened the pack, and therefore its title, its rows, and what picking one does. See below. |
AutoPickup | enabled: bool (default true) | the A toggle: whether auto-explore detours for loot. autoexplore.rs. |
LeftBehind | private: the items turned down, the room the player is in | what auto-explore walks past: items the player dropped, or saw in a room and walked out of without. Never saved; a new floor empties it. autoexplore.rs. |
TargetingState | active, item: Option<Entity>, throwing: bool, cursor_x, cursor_y: i16 | aiming reticle; throwing swaps the range to whatever throw_reach gives the item and confirm to a hurl. |
PlayerTempo | fast_parity: bool | the player half of the speed system: a Fast turn flips it, monsters move only when it flips back. |
AttackQueue / UseQueue / ThrowQueue / SpellQueue | Vec<…> | see Events above. |
Shake | enabled: bool (-nshake), plus a private kind + age | screen shake. Gameplay arms one with shake::kick_shake(world, ShakeKind::…) and forgets; the engine ages it, reads offset() and settle()s it. See below. |
Spirits
Peaceful Faction::Spirits mobs (spirits.rs): melee on one triggers an event instead of an attack.
| Type | Kind | Data | Saved? |
|---|---|---|---|
Alignment | component, hero | i8, -3 (cacodaemon pole) to 3 (eudaemon pole); a spirit poofing (a deal done, a kill, a polymorph) or the pink demon joining moves it ∓1; gaining a Helper moves it +1, and losing one to an explosion (a new Helper replacing it) or a system shock moves it -2; the HUD shows it as hud::balance_gauge (BALANCE, one letter lit; BROKEN once SpiritsHostile) | yes |
SpiritsHostile | resource | bool; set for good when Alignment hits a pole or the player lands a direct hit on a peaceful spirit | yes |
SpiritKind | component | Cacodaemon / Eudaemon; which way the spirit pulls Alignment | transient (from MonsterDef::spirit_kind) |
SpiritEvent | component | fn(&mut World, Entity, Entity); what melee does to it | transient (from MonsterDef::spirit_event) |
TestOfFaithLedger | component | marker; the angel’s blessing is pending until the next staircase | no |
OfferMenu | resource | open, selected, options: Vec<OfferOption>, source: Option<Entity>; the “choose one of three” menu, rolled once on open | transient |
OfferOption | enum | Spell / Weapon / Armor / Ring; one row of that menu | transient |
BarterMenu | resource | open, demon, column: BarterColumn, cursor, player_side / demon_side and player_selected / demon_selected: Vec<Tradeable> | transient |
BarterColumn | enum | Player / Demon; which column the cursor is in | transient |
Tradeable | enum | Item(Entity) / Spell(SpellEffect); one thing on either side of a barter | transient |
Also here: SpellsMenu (open, selected; the Z menu, the only way to an active spell) and SpellKind (Attack / Skill; a staff’s TurboMagic scales only the first). ExtraMonsterRound(bool) is set by a greatclub’s heavy swing; the turn loop runs one extra monster round after that action and clears it.
The pack screen
PackMode (pack.rs) is a four-column table — title, “nothing to show” line, verb, row filter — with one row per key that opens the pack:
| Mode | Key | Shows | Picking a row |
|---|---|---|---|
Browse | i | everything | opens the ItemAction::MENU modal |
Use | a | everything | ItemAction::Use |
Throw | t | everything | ItemAction::Throw |
Drop | d | everything | ItemAction::Drop |
Equip | e | anything with Equipped | ItemAction::Use |
Quaff | q | anything with Potion | ItemAction::Use |
Read | r | anything with Scroll, Deck or Rune | ItemAction::Use |
Zap | z | anything with Wand | ItemAction::Use |
Wield | w | Equipped { slot: Hand } | ItemAction::Use |
Wear | W | Equipped { slot: Body } | ItemAction::Use |
PutOn | P | Equipped { slot: Finger } | ItemAction::Use |
Everything but Browse, Throw and Drop is Use, because “quaff”, “read” and “wear” are all one verb once item_system has the item in hand — the mode only decides what you were offered.
ItemAction::MENU is the fixed Use / Throw / Drop / Info order of the Browse modal (ItemAction::at(idx) indexes it).
pack_rows(world, mode) is the single place the filter is applied, and it returns backpack indices. Both the renderer and the cursor key off that list, so a menu can never highlight or act on a row it isn’t showing, and a row keeps its pack letter in every mode (the potion that is c in the pack is c in the quaff menu, alone on screen or not).
Worn gear still appears on the equip menus — that is how it comes back off.
The screen shake
Shake (shake.rs) is the effect layer’s second half, and the only cosmetic resource that is deliberately not played the way [Particles] is. These call sites arm it, and nothing else may:
ShakeKind | Armed by | Shape |
|---|---|---|
Hit | the light stuff. Anything of the player’s that got through armour: combat::resolve_attack on an ordinary blow — not a crit (that is Heavy), not a kill (that is Kill), never a glancing blow; items::throwing::strike_victim on a throw or shot that drew blood; items::wands::fire_bolt on a bolt that bit something the player can see; traps::trap_flourish when a shooting trap (arrow or dart) goes off in sight, hit or miss — there the shake is the mechanism firing, not the damage; and traps::burst on the first burst of a trick shot the player can see — deliberately the lightest kick there is, because a trick shot is a chain and a chain of heavy thumps is a map that never stops moving | 80 ms, 1 cell — a tick |
Kill | combat::kill_shake, from resolve_attack and finish_indirect_kill, when the player can see the victim’s tile | 120 ms, 1 cell — short |
Heavy | everything that hits hard: combat::resolve_attack on the player’s own excellent hit and on a garrote’s pop; items::wands::elemental_blast if the player can see the blast centre; items::rings::do_it_with_style, the adornment / victory flourish; and the three spells that go off with a bang — circle_of_death, meteor_strike, frost_nova | 260 ms, 2 cells — medium |
Wounded | helpers::warn_if_newly_low, on the same crossing that logs “You are badly wounded!” | 460 ms, 2 cells — long, and the heaviest there is |
The player’s own death arms nothing. Both player-death paths in combat.rs blank the @ and set Ending::player_dead without kicking the map: a death is watched, not felt through the floor. What replaces the shake is time — helpers::death_burst runs the player’s burst at PLAYER_DEATH_STRETCH (3x) the length of a monster’s, which it can afford because nothing is waiting behind it: the run is over and the death screen is next.
The durations and amplitudes are in constants::shake, read as one table by ShakeKind::shape(). A kick only displaces an already-running shake if it is worth more than what is left of it, so a kill mid-blast cannot truncate the blast — and an ordinary hit landed during either cannot truncate anything. Amplitude 2 means the first half throws the map two cells and the rest one; a terminal has no half-cell to decay through.
No kind may be shorter than two of play_shake’s 33 ms frames: it ages the shake before it draws, so anything shorter would retire without ever displacing a frame the player saw. Hit’s 80 ms is that floor. Nothing asserts it, so the constraint lives in ShakeKind::shape’s doc comment, and a kind that breaks it shows up as a shake nobody can see.
A glancing blow is the one hit that draws blood and arms no shake. It gets Particles::clink_spark instead of the landed hit’s hit_spark — same shape, no warm colour, gone quicker — because the chip-damage floor exists so a turned-aside swing isn’t nothing, not so it lands like a real one. A throw or shot the armour turned aside (strike_victim’s “glances off” branch) is the same rule at range, and takes the same spark — harsher, in fact: there is no chip-damage floor out there, so it deals nothing at all.
Melee excludes a lethal blow from the Hit kick by hand; the ranged path excludes it by asking whether the victim is at 0 HP, because helpers::apply_damage leaves a lethal shot for reaper_system to finalise. Either way the kill’s own kick — the sight-gated one — is the only shake a killing hit arms.
The sight gate on elemental_blast, kill_shake and fire_bolt is a real rule, not politeness: a shake for a blast — or a death — in an unexplored room would hand the player information the renderer goes out of its way not to draw. helpers::player_sees is the shared check, the same one the trap messages use.
Melee and the throw path need no such gate, because both log the damage they deal whether or not it was seen — the shake says nothing the message line hasn’t. A bolt is the one damage source that logs nothing per victim, so trace_bolt reports whether any of the HP it took came off a creature on a visible tile (Bolt::bit_something_seen), which is why that answer is computed there rather than in fire_bolt: the walk is the only place that still knows which tile each victim stood on.
Unlike every other animation in the game the shake never blocks input — see rendering.md, “The screen shake”, for why and for how play_shake enforces it.
Run state
| Resource | Fields | Saved? |
|---|---|---|
GameLog | history: Vec<String> (capped at LOG_HISTORY_CAP), unread: Vec<LogEntry> (waiting for --MORE--) | transient — not saved; a reload starts with a fresh log (“Welcome back to nihilurk!”) |
Depth | what: u8 — current floor, 1-based | yes |
FloorChanges | count: u32 — staircase/portal/trapdoor traversals this run; salts content_rng so a repeat visit re-stocks the same layout | yes |
PlayerName | what: String | yes |
Kills | by_name: BTreeMap<String, u32> — every corpse the score paid for, under the name it died with; read by the run dump | yes |
DungeonLord | idle_turns: u32 — turns lingered on this floor; at DUNGEON_LORD_PATIENCE a portal opens | transient (resets to 0) |
GameLog::add() pushes plain text to both history and unread. GameLog::add_colored(message, category) is the same, except the copy that lands in unread — a LogEntry — also carries a LogCategory. history never gets painted, so it stays bare String.
The log panel is plain white except for a sparing set of colours, one LogCategory per: a curse taking hold (dark red), a dazzle (magenta), the low-HP warning (red — “You are badly wounded!”, fired once as HP crosses down through constants::player::LOW_HP_WARNING_FRACTION of max, by helpers::warn_if_newly_low — which helpers::apply_damage calls for every trap, dart and bolt, and combat::resolve_attack calls directly, melee being the one damage path that applies its own damage and would otherwise never report the crossing), the player’s own speed shifting (cyan hasted, dark cyan slowed), or the player’s own throw/fire (yellow). A trick shot and a combo’s “With style.” are magenta too. Every category is decided once, by the call that writes the message, and never re-derived from the rendered sentence — see components::LogCategory and hud::log_paint.
Colour is per message, not per painted row. Several messages share a row (hud::pack_line_segments, which log_view returns — the pieces of messages on each row, in order, displayed joined by one space; a message too long for its row wraps at a word boundary onto the next, each piece keeping the message’s category), and the renderer paints each one with its own colour. Asking the question of the joined row instead would repaint every sentence beside one shouting message.
hud::log_paint(entry, stripes) is the painter’s entry point and returns a LogPaint: Solid(Color) for every category but LogCategory::Pride, which comes back Striped — the one line that comes out in colours rather than a colour (hud::pride_line(), “With pride.”, the rare alternative to “With style.” on a combo), painted a character at a time, cycling the stripes so red follows purple and no two neighbouring letters match. The stripes come from pride::stripes(world); see models/src/pride.rs.
Other run-state resources live outside this file: Map (map.rs), GameRng / RngSeed / FxRng (map/streams.rs), BloodStains, Smoke and Corpses (map/overlays.rs), GameState (state.rs). The save file persists the RNG state and the dark-tile set — see models/src/saveload.rs.
No cosmetic state is saved, on purpose. BloodStains, Corpses, Smoke, Particles, Shake, ScoreFlash and FxRng are all rebuilt empty by load_game. A fire blast’s lingering puffs (SMOKE_LINGER_TURNS, ticked by smoke_system), a death’s corpse marks (helpers::death_burst) and the blood under a fight say nothing the game needs back — and the three map-sized overlays alone would come to 2.2 KB, more than the whole save file they would be joining. A reloaded floor is the floor you left, scrubbed of the mess you made on it. The same reasoning keeps the map itself and the message log out; the four exclusions are listed at the top of saveload.rs.
FxRng (map/streams.rs) is a second RNG stream, seeded from the run seed but salted apart from GameRng — for animation/particle randomness only (a death burst’s fling direction and reach, a blood splatter’s spray). Nothing that reads it feeds back into gameplay, so a purely cosmetic feature (-nb skipping the roll entirely, say) can never perturb the shared GameRng stream everything else depends on for determinism. Not saved — a reload just reseeds it fresh.
See also
content-tables.md the tables that attach these components
spawn-api.md the functions that build entities
../how-to/work-with-the-ecs.md reading and changing these
../explanation/ecs-in-nihilurk.md what each of the three nouns may be
input-and-turn-loop.md what reads and writes the UI resources above
../how-to/add-an-effect.md adding a new marker / modifier component
../how-to/add-a-body.md adding a new hand-written Body variant
../explanation/data-driven-content.md why behaviour is not in the row
Reference: conducts
Audience Engine developer adding a conduct, or anyone reading
a run dump or the leaderboard.
Prerequisites Rust, and a passing familiarity with bevy_ecs.
Status As the source is. If this page and the source
disagree, the source is right and this page is a bug.
A conduct is something a run can go without. A run that wins is paid 10% of its score for each conduct it kept, added on top (additive: fourteen kept is +140%). A run that dies is paid nothing, and its dump lists no conducts.
Everything here lives in models/src/conduct.rs and is re-exported as models::conduct, bar Conduct and Conducts, which are at the crate root.
The fourteen
Each conduct breaks on one act, at one place in the code. Every site below is player-only: monsters share the code and have no conducts.
| Conduct | Breaks when the player | Site |
|---|---|---|
Potions | drinks a potion | resolve_use, models/src/items.rs |
Scrolls | reads a scroll | resolve_use |
Wands | zaps a wand, or throws one | resolve_use; resolve_wand_throw, throwing.rs |
Runes | invokes a charged rune | resolve_use (an inert rune does not count) |
Spells | pays for a spell | pay_for_spell, items/spells.rs |
Coins | takes a coin | spend_pickup, items/pickups.rs |
Ranged | throws or fires anything | resolve_throw, items/throwing.rs |
Attacking | lands a melee swing, lunge or reach | land_swing, combat.rs |
Charming | charms a creature | charm, companion.rs |
Companion | recruits a boon companion | recruit, companion.rs |
Damage | loses HP | took_damage, helpers.rs; the angel’s test of faith |
Gear | puts gear on | toggle_equipped, equipment.rs |
Armor | has armor take a killing blow | armor_takes_last_hit, helpers.rs |
Lurk | is not the lurk | not tracked; read off the Lurk marker |
Gear is deliberately not in equip_silently, so the starting kit does not break it, and taking gear off does not either. A charm scroll or wand breaks Charming and Scrolls or Wands together; a creature that becomes the Helper breaks Companion.
Known gaps
Damage is “HP never dropped”, and three paths lower the player’s HP without going through took_damage: system shock from a polymorph (system_shock, wands.rs), the devil’s max-HP price (pay, spirits.rs), and the vampire’s max-HP drain (vampiric_drain, abilities.rs). A run can keep Damage through them. The drain only matters when its hit did no damage. They are left unhooked on purpose.
The tally
pub enum Conduct { Potions, Scrolls, /* ... */ Lurk }
pub struct Conducts { pub broken: u16 }
pub fn broke(world: &mut World, conduct: Conduct)
pub fn player_broke(world: &mut World, who: Entity, conduct: Conduct)
pub fn kept_mask(world: &mut World) -> u16
pub fn bonus_percent(mask: u16) -> i64
Conducts is a resource, one bit per Conduct in Conduct::ALL order; a set bit is a broken conduct. broke does nothing in a world with no Conducts, so a bare test world needs no setup. player_broke is broke behind a Player check.
kept_mask is what a run has kept: every conduct but the broken ones, with Lurk kept only when the player carries the Lurk marker. It is the one mask the payout, the leaderboard, the dump and the win screen all read.
The order of Conduct::ALL is a file format, because the leaderboard stores the mask by bit index. A new conduct goes on the end. Nothing is ever reordered.
Paying out
pub fn pay_conducts(world: &mut World) -> u16
models/src/score.rs
Settles the turn’s kills, adds bonus_percent(kept_mask) of the score on top, and returns the mask it paid for. It is called once, by run_victory_screens in engine/src/main.rs, before the leaderboard is written, so the board, the dump and the win panel all see the bonused score. Calling it twice pays twice.
Where it is stored and shown
| Where | What |
|---|---|
| Save | SaveGame::conducts, the broken mask. Added with a SAVE_VERSION bump. |
| Leaderboard | Entry::conducts, the kept mask (zero for a loss). LEADERBOARD_VERSION 2. |
| Win screen | One conduct::MARK per kept conduct, from conduct::marks, under the score. |
| Run dump | A -- Conducts (+N%) -- section on a win, one name per kept conduct. |
-scores | The marks, uncoloured, after each line. |
A version 1 leaderboard is still read, with every mask zero, and becomes version 2 on the next write. Any other version is refused and left alone, as before.
marks(mask, stripes) colours the marks by cycling stripes by the conduct’s place in Conduct::ALL, so a conduct keeps its colour whichever others are broken. The win screen passes the run’s pride flag (pride::stripes). Lurk is always LURK_COLOR, the colour of the lurk’s own @; a test fails if the two drift apart. The names come from strings::conduct_name, indexed by bit.
See also
constants.md the score constants the bonus sits on cli-and-env.md -scores, the run dump and the data directory rendering.md the win panel the marks are drawn on ../explanation/ecs-in-nihilurk.md resources, and why the tally is one
Tuning constants
Audience Anyone rebalancing the game — changing how much a
wand hurts, how cursed the loot is, how fast the
Dungeon Lord evicts you.
Prerequisites None.
Status Describes `models/src/constants.rs` as it is in the
source. If this page and the source disagree, the
source is right and this page is a bug.
The dry facts: where each knob lives and what it does. Why the numbers are what they are is ../explanation/combat-and-balance.md.
Every balance number in the model is defined in one file:
models/src/constants.rs
It is organised into pub mod blocks by domain, and every constant carries a doc comment explaining what it does and — where it matters — what else you have to touch if you change it. The file itself is the reference; this page is the map to it.
The module that uses a constant re-exports it (often under its old name), so existing call sites and doc links keep working. You change the value in constants.rs and nowhere else, unless a doc comment there tells you otherwise.
The modules
constants:: | Holds | Read when you want to change |
|---|---|---|
combat | Excellent-hit odds and dice, the chip-damage floor, the odds a corpse keeps each piece of gear, a shattered cursed suit’s splinters | how swingy a fight is |
player | Starting HP / armour / power / magic, sight radius | the hero’s opening position (new games only) |
lurk | Starting HP / magic (-b lurk), GROWTH_CHANCE and GROWTH_STEP for feeding off a kill | how the lurk starts and how fast it grows |
progression | FINAL_DEPTH, DUNGEON_LORD_PATIENCE, the staircase heal divisor, DIFFICULTY_TIER_LAST_DEPTH (the depth bands the crowding budgets step at) | how long a run is and how hard attrition bites |
map | WIDTH, HEIGHT, dark-room chance | the playfield (see the determinism caveat below) |
population | Monster / trap / item budgets per floor, the placement retry budget (PLACEMENT_TRIES), how they scale with the difficulty tier, how many runs of the budgets a battlefield, vault or bee world gets, corridor lurkers, hidden items, the cutoffs the level feeling reads (FEELING_MONSTERS, FEELING_ITEMS) | how crowded and dangerous a floor is, and how often a level feeling speaks |
potions | What a dose is worth: the max-HP a healing / extra healing potion adds, the power gain strength adds and poison takes (with its floor), the share of turns paralysis eats | how much a potion swings a run |
scrolls | What one enchantment is worth (and that a minus is mended whole), how long sleep and hold last, how often sleep backfires on the reader | how strong the room-clearing scrolls are |
decks | DECK_SIZE, REVERSED_MIN / REVERSED_MAX, CARD_CHAIN_CAP, BALA_POWER, BOLE_ARMOR, WORLD_TURNS, CARD_CASTER_CASTS, GOLDEN_WIND_SUMMONS, CARD_POINTS (what each card in a thrown hand pays) | how wild a deck of cards runs |
runes | PROTECTION_TURNS, RECHARGE_STEP, RECHARGE_CAP | how long a rune of protection holds and how much one of recharging gives |
traps | Arrow / dart damage dice, TRAP_DAMAGE_TIER_LAST_DEPTH and the per-tier bonus / strength drain, the bear-trap thrash, the trick-shot burst (TRICK_SHOT_RADIUS, PICKUP_TRICK_SHOT_RADIUS and the shared dice), TRAP_BREAK_CHANCE | how much a trap hurts and how fast it scales |
wands | WAND_CHARGES, zap damage dice, both blast radii, per-charge dice a thrown wand spends, DIG_RANGE (how deep a wand of digging bores) | how good a wand is |
loot | Enchantment odds (normal / exceptional / cursed), the bonus ranges, ammo bundle size, the improvised-throw die | how the drop table feels |
items | THROW_RANGE, LIGHT_THROW_RANGE, LAUNCHER_RANGE, STACK_LIMIT, PACK_CAPACITY | reach and pack density |
rings | STEALTH_RANGE (how close a stealthy player is noticed at), TELEPORT_MAGIC_COST, and the numeric rings’ quality: PLAIN_BONUS, EXCEPTIONAL_BONUS, CURSED_BONUS_MIN, CURSED_BONUS_MAX, MEGACURSED_BONUS_MIN, MEGACURSED_BONUS_MAX | the two rings with a number that isn’t on their row, and what a numeric ring rolls |
score | KILL_PER_MAX_HP, COMBO_BONUS_PER_KILL, COMBO_PRIDE_CHANCE, STAIR_PER_TIER, SCORE_FLASH_TURNS | what the run is scored on, and how loudly |
monsters | DEFAULT_SPAWN_WEIGHT | the baseline rarity a bestiary row gets |
spirits | ALIGNMENT_POLE (how far alignment drifts before spirits turn hostile for good), SPAWN_WEIGHT, PINK_DEMON_ODDS_PER_PIECE (each piece of gear the pink demon destroys adds this to the odds they join you), the red demon’s RED_DEMON_GEAR_PRICE (Max HP) and the gnome’s GNOME_SCROLL_PRICE / GNOME_POTION_PRICE / GNOME_WAND_PRICE (Max Ma), the barterer’s BARTER_STOCK_MIN / BARTER_STOCK_MAX, the ALIGNMENT_STEP a poof moves alignment by, the HELPER_GAINED_ALIGNMENT / HELPER_BLOWN_UP_ALIGNMENT a Helper gained or blown up moves it by, and the angel’s test of faith (TEST_OF_FAITH_HP_DIVISOR, TEST_OF_FAITH_GEAR_BONUS, TEST_OF_FAITH_DUD_BONUS) | how common spirits are and how fast they turn on you |
helpers | ACCEPT_CHANCE, SHAPESHIFT_CHANCE | how often a thrown treat wins a Helper, how often a dog’s or mirror hound’s kill changes its shape, once |
spells | SPELLSET_CAP, the dice behind the spells that roll their own (Thunderbolt, Force Lance, Circle of Death, Frost Nova), the stand-in batteries (LUX_CHARGES, METEOR_STRIKE_CHARGES), and a staff’s TURBO_MAGIC_COST_MULT / TURBO_MAGIC_POWER_MULT | how much a spell costs and how hard it lands |
travel | Step caps on autoexplore / fast-move, the HP divisor below which auto-fight refuses | only if a walk loops, or auto-fight is too brave or too timid |
hud | Message-log rows shown at once, the two wrap widths (normal and --MORE--), and how much scrollback LOG_HISTORY_CAP keeps | the log’s footprint on screen |
abilities | The per-turn odds of the passive abilities (aggravate monster, regeneration, teleportitis, polymorphitis) and MOMENTUM_PER_HIT | how often a ring does its thing on its own |
conditions | CONFUSION_STUMBLE_CHANCE, the share of a confused player’s steps that go astray | how much confusion costs (paralysis’s dial is in potions) |
speed | SLOW_RATE, NORMAL_RATE, QUICK_RATE, FAST_RATE and the ACTION_COST they are read against | how often each tempo acts |
layout | The grid ordinary floors are laid out on: SECTIONS, GUTTER, PADDING, the smallest room, EMPTY_SECTION_CHOICES | the shape of a floor (moves every layout on every seed) |
special_levels | The carving dials of the labyrinth, vault, bee world, castle and island | the shape of one special level (moves its layout on every seed) |
ice | A kicked ice cube’s cold dice and flight range, and how much bone and vapor it shatters into | how hard the cube hits, how showy it ends |
shake | How long each kind of screen shake lasts and how far it throws the map | how hard a blow feels |
Two caveats worth repeating
map::WIDTH / map::HEIGHT pin every seed. models/tests/it/determinism.rs asserts that a given seed produces the same walls it always has. Changing either dimension moves every wall on every seed and that test will fail — correctly. Only change them as a deliberate “all old seeds are void” decision.
Enchantment odds are read as ranges against a 0..100 roll. loot::NORMAL_QUALITY_PCT and loot::EXCEPTIONAL_QUALITY_PCT must sum to 100 or less; the remainder is the cursed share. The prose odds tables in this directory’s content-tables.md and in catalog::Quality::roll do not update themselves.
What is deliberately not in constants.rs
- Content-table numbers — a monster’s HP, a weapon’s die, a potion’s colour. Those are data, one row per thing, in
catalog.rs/monsters.rs/traps.rs. Seecontent-tables.md. - Animation timing — the millisecond figures in
particles.rs(bolt speed, blast ripple) and the wave counts inmagicmap.rs. Presentation feel, wound tightly around the code that reads them. The shake table is the exception, inconstants::shake. - RNG salts and the neighbour-offset tables. Structural, not balance.
See also
constants.rs the file itself, fully commented
../../engine/src/constants.rs screen size and frame/pause timing in the binary
../../particle-core/src/constants.rs ripple and shake clocks, the shake pattern
../explanation/combat-and-balance.md why the numbers are what they are
content-tables.md the per-row content numbers
Reference: content tables
Audience Anyone editing content. Look things up here; do not
read it end to end.
Prerequisites None.
Status Describes the tables as they are in the source. If
this page and the source disagree, the source is
right and this page is a bug.
Every table in the game, every field, every default.
%%{init: {'theme':'base','themeVariables':{
'primaryColor':'#20242b','primaryTextColor':'#d7dae0',
'primaryBorderColor':'#5c6370','lineColor':'#8a8f98',
'fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace',
'fontSize':'13px'}}}%%
flowchart LR
SEED(["seed + depth"]):::cold --> LAY["layout_rng"]:::cold
LAY --> GEN["generate<br/><i>rooms, corridors,<br/>stairs, the dark</i>"]
SEED --> CON["content_rng<br/><i>+ staircases taken</i>"]:::cold
CON --> POP["population<br/><i>how many, and where</i>"]
POP --> BEST["BESTIARY<br/>MonsterDef::pick"]:::peril
POP --> DROPS["DROPS<br/>roll_item"]:::magic
POP --> TRAPS["TRAPS<br/>TrapDef::pick"]:::peril
DROPS --> CAT["POTIONS · SCROLLS · RUNES · WANDS<br/>WEAPONS · AMMO · LAUNCHERS<br/>ARMORS · RINGS · COINS"]:::magic
GEN --> FLOOR(["a floor"]):::hero
BEST --> FLOOR
CAT --> FLOOR
TRAPS --> FLOOR
classDef hero fill:#3a3418,stroke:#d7ba4a,color:#e8dfa8
classDef peril fill:#3a1f1f,stroke:#c05050,color:#f0c8c8
classDef magic fill:#2f2038,stroke:#a86fc0,color:#e6cdf0
classDef cold fill:#17323a,stroke:#4aa3c0,color:#bfe4f0
Where everything is
| Table | File | Row type |
|---|---|---|
BESTIARY | models/src/monsters.rs | MonsterDef |
POTIONS | models/src/catalog.rs | PotionDef |
SCROLLS | models/src/catalog.rs | ScrollDef |
RUNES | models/src/catalog.rs | RuneDef |
WANDS | models/src/catalog.rs | WandDef |
WEAPONS | models/src/catalog.rs | WeaponDef |
AMMO | models/src/catalog.rs | AmmoDef |
LAUNCHERS | models/src/catalog.rs | LauncherDef |
ARMORS | models/src/catalog.rs | ArmorDef |
RINGS | models/src/catalog.rs | RingDef |
COINS | models/src/catalog.rs | CoinDef |
TRAPS | models/src/traps.rs | TrapDef |
DROPS | models/src/spawn.rs | DropCategory |
EFFECTS | models/src/effects.rs | Effect |
ABILITIES | models/src/abilities.rs | Ability |
FLAGS | models/src/pride.rs | PrideFlag |
For the live contents of any of them:
cargo run -p nihilurk -- -content
BESTIARY — MonsterDef
Constructor: MonsterDef::row(name, glyph, color, movement, hp, power, power_bonus, armor, armor_bonus, min_depth), then optional chains.
| Field | Type | Def. | Meaning |
|---|---|---|---|
name | &'static str | – | Unique; its save identity. |
glyph | char | – | One character on the map. |
color | Color | – | See the palette below. |
movement | MovementType | – | Static/Chase/Flee/Confused |
hp | i32 | – | Hit points; also max_hp. |
power | i32 | – | Attack die: 1d[power]. |
power_bonus | i32 | – | Flat, once, on that roll. |
armor | i32 | – | Defence die: 1d[armor]. |
armor_bonus | i32 | – | Flat, once, on that roll. |
min_depth | u8 | – | Shallowest floor it spawns. |
weight | u32 | 10 | Rarity vs eligible pool. |
grants | &'static [Grant] | &[] | Effects it is born with. |
invisible | bool | false | Born unseeable. |
Chains: .grants(&[...]), .invisible(), .weight(n).
Combat: damage = (1d[power] + power_bonus) - (1d[armor] + armor_bonus), both sides rolled independently. Nothing ever misses.
Spawning a row attaches: Name, Mob, Fighter, Renderable, Position, Faction::Monster, Blood, Grants, Speed(Normal), plus each granted effect component, plus Invisible if the row asked.
MovementType::Aggravated { tx, ty } exists but is applied at run time by the scroll of aggravate monsters. Never put it in a row.
A species’ whole special behaviour is its grants. The aquator is the one whose grant reaches for the player’s gear rather than the player: RustsArmor means every blow it lands calls equipment::corrode_armor on what it hit, taking a point off the worn armour’s ArmorBonus (never its die — ruined plate is still plate, and the plus can go negative). A scroll of enchant armour mends it; a ring of maintain armor (SustainsArmor) stops it; a wand of cancellation takes the corrosion out of the aquator.
Item tables
Every row implements ItemDef, which supplies:
fn name(&self) -> &'static str required
fn spawn(&self, world, pos) -> Entity required
fn weight(&self) -> u32 default 10
fn min_depth(&self) -> u8 default 1
fn spawn_as_loot(&self, world, rng, pos) default: calls spawn
Only the scroll of identify and the scroll of remove curse override weight (twice the baseline); no item row overrides min_depth. Every other row picks evenly on purpose.
POTIONS — PotionDef
| Field | Type | Notes |
|---|---|---|
effect | PotionEffect | Keys the mechanic; identity in saves. |
name | &'static str | |
color | Color |
Draws !. Attaches Item, Potion, Consume. Mechanic: apply_potion_effect in models/src/items/potions.rs. That match has no catch-all, so a new PotionEffect variant will not compile until it has an arm — same rule as TrapEffect above. An arm is allowed to do nothing on purpose (FruitJuice and Water are a log line and a taste); every other arm bites.
The dials the arms read — how much ceiling a dose of healing is worth, how much power poison takes — are constants::potions. Five arms reach straight into Fighter (healing, extra healing, gain strength, poison, restore strength) and one into Magic (the potion of magic, which fills the pool and lifts its ceiling the way gain strength lifts the arm’s), three hand off to crate::conditions (blindness, confusion, paralysis — and haste, via hasten), two tag things Detected, one lends SeesInvisible for the floor, and RaiseLevel calls transition_level upward (and, on Depth 1 with the Element of Yoord in the pack, wins the run outright).
SCROLLS — ScrollDef
| Field | Type | Notes |
|---|---|---|
effect | ScrollEffect | Keys the mechanic; identity in saves. |
name | &'static str |
Draws ?, always white — a scroll has no colour field. Attaches Item, Scroll, Consume. Mechanic: apply_scroll_effect in models/src/items/scrolls.rs. That match has no catch-all, so a new ScrollEffect variant will not compile until it has an arm — same rule as TrapEffect above. Every row bites; BlankPaper is the only arm that does nothing, and it says so.
Each scroll has a flourish of its own — which one, and why that one, is ../explanation/the-feel-layer.md.
The dials the arms read — what one enchantment is worth, how long sleep and hold last, how often sleep backfires — are constants::scrolls. Three arms go off across whatever the reader can see (scare monster, hold monster, sleep, via helpers::hostiles_in_view), two reach into the gear in a slot (the enchantments), one arms the reader’s next blow (monster confusion, spent by the ON_HIT_ABILITIES row below), two tag things Detected — food detection takes precisely what a potion of magic detection rejects, so between them they find every item on the floor exactly once, with the Element of Yoord deliberately turning up for both.
RUNES — RuneDef
| Field | Type | Notes |
|---|---|---|
effect | RuneEffect | Keys the mechanic; identity in saves. |
name | &'static str |
Draws ', always white. Attaches Item and Rune { effect, charged: true }, and no Consume: reading a rune spends its charge (item_system clears charged), and it stays in its pack slot, shown as (inert). A staircase wakes every spent rune in the pack (items::recharge_runes, called from transition_level for LevelChange::Stairs only: a trapdoor or a portal does not). r reads scrolls and runes. Mechanic: apply_rune_effect in models/src/items/runes.rs, exhaustive with no catch-all like apply_scroll_effect.
There is no row for RuneEffect::Blank. A wand of cancellation makes one out of any rune the player carries, and a blank rune never wakes. Displacement is a scroll of teleportation; Chaos and Ice cast Haste Self and Frost Nova for free; Recharging adds RECHARGE_STEP to every wand’s Battery up to RECHARGE_CAP; Justice lends ExplodesOnDeath to everything in view; Protection lends Protected for PROTECTION_TURNS. The dials are constants::runes. Runes drop from floor 3 (see DROPS). Only the player invokes one: a monster that catches a thrown rune does nothing with it.
WANDS — WandDef
| Field | Type | Notes |
|---|---|---|
effect | WandEffect | Keys the mechanic; identity in saves. |
name | &'static str | |
color | Color | |
range | i32 | Feeds the aiming reticle. In use: 6, 8. |
Draws /. Attaches Item, Wand, Ranged, Battery. Every wand spawns full, with WAND_CHARGES, however it was made. Zap damage dice and both blast radii live in models/src/constants.rs → wands (see constants.md). Whether zapping opens the reticle: WandEffect::needs_target, which is true for everything except the wand of light. Mechanic: apply_wand_effect in models/src/items/wands.rs. That match has no catch-all, so a new WandEffect variant will not compile until it has an arm — same rule as TrapEffect above; every existing variant already does something real. Throwing a wand is resolved in models/src/items/throwing.rs (resolve_wand_throw), a narrower, still-_-fallback dispatch over only the six utility effects a thrown blast can carry (apply_thrown_wand_effect) — it is not the “unwired effect” checkpoint; apply_wand_effect is.
Every wand’s zap, burst, poof and blast palette is described in ../explanation/the-feel-layer.md; none of it is a field on this table.
A thrown one bursts only on impact like any wand, so aim it at the rock. A zapped wand of digging (dig_tunnel) turns every wall on the aim’s line, out to DIG_RANGE tiles, into passage, and never what Map::diggable refuses: the map’s outer wall, and the floor and bounding walls of any room whose SpecialRoom::undiggable marker is set. Only the red room sets it. A tunnel is not in the seed, so the save carries it (dug_tiles, found by diffing the live map against map::pristine_tiles).
A zapped wand of swapping (swap_with_target) trades the zapper’s tile for that of one thing on the aimed tile: a creature first, then a trap somebody has found, then an item. A hidden trap is passed over, the same rule as traps::detonate_at. Nobody is set down where they could not stand (can_stand: a phasing creature anywhere, a swimmer in deep water too, everyone and everything else on dry floor), so a ghost in the rock refuses the swap. Like a teleport, it springs no trap and lifts every hold (effects::HOLDS) on both sides.
Neither a zap nor a throw can be aimed at the player’s own tile: the engine refuses it with “Great idea! But no.” and no turn passes.
Thrown, a wand bursts on impact and spends every remaining charge at once. Which blast it makes is three rows:
| Wand kind | Radius | Damage | Payload |
|---|---|---|---|
attack (is_attack_wand) | GRENADE_RADIUS | GRENADE_DIE_PER_CHARGE sides per charge, armour-ignoring | its element, if it has one |
| wand of light | GRENADE_RADIUS | the same | dazzle on every creature caught |
| utility | BLAST_RADIUS | none | the wand’s own effect, on every creature caught |
| wand of nothing | — | none | confetti |
| wand of digging | GRENADE_RADIUS | none | a crater: every wall in the disc goes, in sight or not (wands::crater) |
| wand of swapping | BLAST_RADIUS | none | every creature caught on dry floor moves to another’s tile, in a random ring so none keeps its own (wands::shuffle_places) |
Dials: constants::wands. Resolution: resolve_wand_throw and apply_thrown_wand_effect in models/src/items/throwing.rs. What each of those looks like on screen is ../explanation/the-feel-layer.md; what a condition does to the player it lands on is components.md.
SPELLS — SpellDef
The one catalog row that never spawns anything: a spell carries no Item, no Position, no pack slot. It lives permanently in whoever’s Spellset it’s in (the player’s, taught by a hero coin — see “COINS” above) and is triggered from there (the Z menu, by row letter).
| Field | Type | Notes |
|---|---|---|
effect | SpellEffect | Keys the mechanic; identity in saves (a Spellset is Vec<SpellEffect>). |
name | &'static str | Shown on the Z menu and in “You unleash your ___!” |
cost | u8 | Magic points one use spends, times constants::spells::TURBO_MAGIC_COST_MULT when TurboMagic (a staff) meets an Attack — which multiplies that spell’s damage by the larger TURBO_MAGIC_POWER_MULT in the same breath. |
range | i32 | Feeds the aiming reticle. Meaningless — left at 0 — for a spell whose SpellEffect::needs_target() is false. |
kind | SpellKind | Attack or Skill — the same split WandEffect’s attack/utility divide makes, and the one TurboMagic checks. |
One row per SpellEffect, in four Magic-cost tiers, any of which a coin or spirit may teach (SpellDef::learnable). SpellEffect::needs_target says whether a spell opens the aiming reticle at all (an attack aimed at a tile) or fires on the caster/everyone-in-view the instant its slot is pressed (a self-cast skill, or a room-wide attack like Circle of Death) — the same courtesy WandEffect::needs_target gives the wand of light.
Mechanic: apply_spell_effect in models/src/items/spells.rs, keyed by SpellEffect, exhaustive with no catch-all like every other effect table in the game. Several rows are literally another category’s own mechanic under a different name rather than a reinvention — Identify and Magic Mapping call straight into scrolls::apply_scroll_effect; Lux and Meteor Strike call wands::elemental_blast with a stand-in charge count, because a spell has no battery to read one off. Sting borrows the dart trap’s own formula (traps::trap_damage_tier) rather than a fresh roll.
MagicWard (the spell) is an EFFECTS row like any other marker, lent for Lifetime::Floor by effects::lend, and it is checked in exactly two places — helpers::apply_hit (every Hit whose magical flag is set, zapped, thrown, breathed or cast, bounces off with a cosmetic ricochet, wands::ward_ricochet) and abilities::fire_on_hit (nothing a monster’s landed blow carries with it takes hold). Lifted at the next staircase like any other floor-scoped effect, and named on the way out as a boon rather than an affliction (conditions::FLOOR_BOONS, conditions::clear_player_conditions).
Bided (Bide) is likewise a plain marker: combat::fold_matchup folds constants::combat::BIDE_ATTACK_BONUS into the bearer’s next attack roll, and combat::resolve_attack removes the marker the instant that roll is folded — hit, glancing or miss, and before a double-striking estoc or a cleave can see it twice. Anything else the bearer does with a turn instead of landing an attack — a plain step, a used or thrown item, another move cast — spends it unfired: equipment::reset_momentum strips it on the same occasions it zeroes a rapier’s Momentum.
A scroll of amnesia (ScrollEffect::Amnesia) is the one thing that un-teaches a spell: scrolls::read_amnesia drops one random entry from the reader’s Spellset and clears their Viewshed::revealed_tiles for the current floor in the same breath.
WEAPONS — WeaponDef
Constructor: WeaponDef::new(name, color, power_die), then chains.
| Field | Type | Default | Notes |
|---|---|---|---|
name | &'static str | – | |
color | Color | – | |
power_die | i32 | – | Damage rolls 1d[n]. |
thrown_die | i32 | power_die | Die rolled on impact. |
projectile | bool | false | Built to be thrown. |
piercing | bool | false | Throw runs the whole line. |
returns | bool | false | The throw ends back with the thrower. |
reach | i32 | 0 | Aimed rather than walked into: a bardiche (2), a whip (5). |
reach_piercing | bool | false | The reach strike runs the whole line. |
grants | &[Grant] | &[] | Marker effects the wielder holds while it is in hand. |
on_wear | Option<OnWear> | None | A one-shot fired the instant it is wielded. |
on_doff | Option<OnDoff> | None | A one-shot fired the instant it is deliberately put away again. |
Chains: .missile(die) sets thrown_die and projectile; .piercing(), .returning(), .reach(tiles), .reach_piercing(), .grants(&[...]), .on_wear(...), .on_doff(...) each set the field they name.
Draws ). Attaches Item, Equipped::loose(Slot::Hand), PowerDie, ThrownDamage, and Projectile / Piercing / Returns / Reach / ReachPiercing / Grants / OnWear / OnDoff when asked. A floor drop is enchanted (see below). grants, on_wear and on_doff are all re-read from the row on load (catalog::restore_from_catalog), never stored in the save.
The staff is the only row with an on_doff, and the reason is worth repeating: it multiplies what every attacking spell costs and what it does (constants::spells::TURBO_MAGIC_COST_MULT and TURBO_MAGIC_POWER_MULT), and neither multiplier shows anywhere on the HUD. The two log lines are the whole of the player’s notice, which is why the taking-off needs one as much as the putting-on. OnDoff fires only on the deliberate path (equipment::toggle_equipped), never from force_unequip — dropping, being disarmed and dying are not ceremonies, the same asymmetry OnWear already has against equip_silently.
Every throw ignores the target’s armour die, but the armour plus is always subtracted. A thing not made for throwing rolls IMPROVISED_THROW_DIE plus its own plus, and so does ammunition lobbed by hand; armour thrown does no damage. Projectile means two things at once: the missile is spent on what it hits, and nothing can catch it. A non-projectile throw can be caught and used against you.
AMMO — AmmoDef
| Field | Type | Notes |
|---|---|---|
name | &'static str | |
color | Color | |
die | i32 | Rolled when hurled by hand. |
venom | bool | A hit that draws blood saps the victim’s power (Envenomed). The blowdart. |
launched_die | i32 | Rolled instead, once loosed from the launcher that answers to launched_by: an arrow 1d3, a quarrel 1d5, a blowdart 1d2. |
launched_by | Grant | The effect that switches to launched_die. |
Draws ). Attaches Item, ThrownDamage, LaunchedDamage, Projectile, LaunchedBy, Stack { count: 1 }, and Envenomed when venom is set. No PowerDie and no Equipped — there is nothing to wield and nothing to wear.
Stacks to STACK_LIMIT per pack slot. A floor drop arrives as a bundle of AMMO_BUNDLE_MIN..=AMMO_BUNDLE_MAX (constants::loot) — never a lone arrow, because finding one arrow is not finding ammunition.
LAUNCHERS — LauncherDef
| Field | Type | Notes |
|---|---|---|
name | &'static str | |
color | Color | |
grants | &'static [Grant] | Effects lent to whoever holds it. |
melee_cap | i32 | Most it is worth swung. |
Draws }. Attaches Item, Equipped::loose(Slot::Hand), Launcher, ThrowBonus(0), Grants, MeleeCap. Contributes no attack or armour die at all — its enchantment therefore lands on ThrowBonus.
melee_cap becomes a MeleeCap component, which clamps the wielder’s melee damage however the dice fell. A launcher takes the hand a sword would have had; this is what that hand costs.
ARMORS — ArmorDef
| Field | Type | Notes |
|---|---|---|
name | &'static str | |
color | Color | |
armor_die | i32 | Defence roll adds 1d[n]. In use 2-9. |
grants | &[Grant] | Marker effects lent while worn. &[] for plain mail; spikemail’s is Spiked. |
Draws ]. Attaches Item, Equipped::loose(Slot::Body), ArmorDie, and Grants when the row has any.
RINGS — RingDef
Constructor: RingDef::new(effect, name), then chains.
| Field | Type | Default | Notes |
|---|---|---|---|
effect | RingEffect | – | Identity for ident/saves. |
name | &'static str | – | |
power_die | i32 | 0 | Adds to the attack die size, the way power_bonus adds a flat amount. |
power_bonus | i32 | 0 | .power_bonus(n) |
armor_die | i32 | 0 | Adds to the defence die size, the way armor_bonus adds a flat amount. |
armor_bonus | i32 | 0 | .armor_bonus(n) |
throw_bonus | i32 | 0 | .throw_bonus(n) |
grants | &'static [Grant] | &[] | .grants(&[...]) |
on_wear | Option<OnWear> | None | .on_wear(...) — a one-shot fired the moment it goes on |
Draws =, always yellow. Attaches Item, Ring, Equipped::loose(Slot::Finger), each non-zero modifier, Grants when non-empty, and OnWear when the row has one. A zero modifier attaches nothing. grants and on_wear are both re-read from the row on load, never stored in the save.
Every ring is live, and all but adornment are pure table — a modifier combat already folds, or a marker some system already asks about:
| Ring | Row content |
|---|---|
| protection | .armor_bonus(2) |
| strength | .power_bonus(2), grants SustainsStrength |
| perception | grants SeesInvisible |
| aggravate monster | grants AggravatesMonsters |
| sharpshooting | .throw_bonus(2) |
| increase damage | .power_bonus(2) |
| regeneration | grants Regenerates |
| slow digestion | grants Sluggish |
| teleportation | grants Teleportitis |
| polymorph | grants Polymorphitis |
| stealth | grants Stealthy |
| maintain armor | grants SustainsArmor |
| sustain form | grants SustainsForm |
| dualzap | grants DualZap |
| health | .max_hp_bonus(2); the wearer gains the plus in max and current hp on the way on, and only the max comes off, so swapping washes hp and a negative one can kill |
| adornment | .on_wear(items::rings::ADORNMENT) |
The only ring behaviour code in the tree is models/src/items/rings.rs, and it holds four verbs, not a match on RingEffect: the adornment flourish (which the victory climb also calls), the regeneration tick, the teleportitis jump and the polymorphitis roll. Nothing outside that file asks which ring it has.
do_it_with_style is the flourish, and two things call it: wearing the ring, and map::levels::win_with_style (both ways out of the dungeon — the last stair, and a potion of raise level drunk on Depth 1). What it looks like is ../explanation/the-feel-layer.md; what it is worth is score::double.
COINS — CoinDef
Coins are the whole pickup category: an item that is never carried, works where it lies, and is gone. See components.md, “Components — items on the floor and in the pack”.
| Field | Type | Notes |
|---|---|---|
name | &'static str | |
color | Color | |
effect | PickupEffect | Keys the mechanic; identity in saves. |
amount | i32 | The one dial. What it means is effect’s business: score for a treasure coin, hit points for the red one, afflictions lifted for the rosé. 0 for a row that needs no number. |
weight | u32 | This row’s share of the coin table against its table-mates. Ten is the baseline. |
Draws $. Attaches Item, Pickup, and — for a treasure coin only — Value, which is the component the score reads (the relic carries the same one).
| Coin | Effect | What it does |
|---|---|---|
| gold | Coin | amount into the score |
| silver | Coin | the same, less of it |
| red | Health | heals up to amount, never past max_hp |
| blue | Power | refills up to amount magic points |
| rosé | Cleanse | lifts up to amount afflictions, worst first |
| green | Strength | gives back up to amount drained power |
| platine | Platinum | the Plated promise |
| forge | Forge | the Forged promise |
| hero coin | LearnRandomSpell | teaches one random, unlearned spell into the taker’s Spellset (see “SPELLS — SpellDef”). Well under the baseline weight — an uncommon find. Never disguised: it has no appearance and is never identified, because it is always just “a hero coin”. |
Mechanic: apply in models/src/items/pickups.rs, an exhaustive match with no catch-all — a new PickupEffect does not build until it does something.
One of them is not left to chance. Every floor is stocked with a coin for the body before its item budget is spent — a blue one on the odd floors, a red one on the even. The last floor of each difficulty tier (DIFFICULTY_TIER_LAST_DEPTH, constants::progression) also gets one draw from catalog::PROGRESSION_ITEMS: the platinum, forge and hero coins, the three potions that raise a ceiling (healing, magic, gain strength), and the three scrolls that sharpen gear for good (the two enchantments and vorpalize weapon). Neither comes out of the budget. See ../how-to/tune-rarity-and-depth.md, “Dial 1”.
A coin that would do nothing is not taken. pickups::would_help gates every one of them: a red coin at full health, a rosé coin with nothing wrong with you, a platinum coin when you already hold the promise. The coin stays on the floor, silently, and auto-explore skips it too (autoexplore::known_item_tiles) until the day it would help.
A full pack is no obstacle, because there is nothing to find room for. This is the one thing on the floor a full pack can still answer.
TREATS — TreatDef
A treat is only ever thrown. It is an offer of loyalty: see “Helpers” below.
| Field | Type | Notes |
|---|---|---|
name | &'static str | |
color | Color | |
for_item_users | bool | Whether it is meant for a creature with hands (ItemUser). |
Draws %. Attaches Item, Treat and Stack: treats stack like ammo, and split_one throws one at a time. restore_from_catalog puts Treat back on load.
| Treat | Colour | For |
|---|---|---|
| snack | dark yellow | a creature without hands |
| fancy of peace | cyan | a creature with hands |
Treats share the coins’ old slice of the drop table (Rogue’s food slot). The coin guaranteed to every floor, and the coins in a hoard, are still coins.
Use on a treat, or on ammunition (anything with LaunchedBy), logs that it is for throwing and costs no turn (items::use_refusal).
Helpers. A treat that lands on the right kind of monster, thrown by the player, is eaten. On a constants::helpers::ACCEPT_CHANCE roll the monster becomes the player’s Helper (companion::recruit): Faction::Ally plus the Helper component. Anything else the treat bounces off, and it lands. Only one ordinary Helper at a time: recruiting a second explodes the first, cosmetically. A creature with PriorityHelper (the dog) is never that first one and any number can stand beside you; recruiting one explodes the ordinary Helper. AlwaysTamed makes every treat take, AlwaysHelper makes a charm or a conjuring the Helper outright, and ShapeshiftOnKill rolls constants::helpers::SHAPESHIFT_CHANCE on each melee kill to turn the dog into another monster (monsters::shapeshift, which keeps whichever of the five grants it held, and its side). FaerieOnDeath is the reveal: when the dog dies it is revealed as a faerie shapeshifter and is gone, the way a spirit poofs: no corpse, no gore, no score, no blood even from the wounds before (combat::reveal_faerie). The faerie has no body: it is a log line only, in pink (LogCategory::Faerie), “It was never a dog, but a faerie shapeshifter!”, and it stands in for the kill line. The (d) is a (d)oppelganger, or rather a dogppelganger: a faerie shapeshifter wearing a dog. That is why nothing cancels what makes it a dog, why a kill can change its shape, and why no dog ever dies. What “dies” was never a dog. Each is its own grant. All but ShapeshiftOnKill are identity effects that no cancellation strips; the dog row is just all five (monsters::DOG_GRANTS). The mirror hound (monsters::MIRROR_HOUND_GRANTS) swaps ShapeshiftOnKill for MirrorOnKill: each melee kill rolls SHAPESHIFT_CHANCE (25%) to turn it into exactly the species it killed (monsters::mirror), keeping its side and its other grants. Either shapeshift happens once: the changed creature has lost ShapeshiftOnKill or MirrorOnKill. Cancel either first and it never changes shape. A Helper:
- goes for the nearest monster on a tile the player can see, shooting if it holds a launcher, and otherwise comes back to the player’s side (the
HELPERrule set inagents.rs); - is hit back by a monster next to it that is not next to the player;
- trades places with the player who walks into it, and is never hit by the player’s cleave, whirl, lunge, or auto-fight;
- every Helper follows the player to every new floor at full HP (
companion::follow_downstairs), gear and all; - pays no score when it dies (
companion::mourn).
Helper is a plain component, saved as its own field, not an EFFECTS row: a wand of cancellation does not undo loyalty.
The relic
Not a table — one function, spawn_element_of_yoord. Draws " in magenta, carries a Value and an Amulet. Its name is the constant ELEMENT_OF_YOORD. Spawned in place of the down-stair on the deepest floor (FINAL_DEPTH, constants::progression).
TRAPS — TrapDef
| Field | Type | Notes |
|---|---|---|
effect | TrapEffect | Keys the mechanic; identity in saves. |
name | &'static str | What TrapEffect::label() returns. |
glyph | char | '^' for every row. |
color | Color | One per row. |
weight | u32 | Rarity relative to its table-mates. |
min_depth | u8 | Shallowest floor it can spawn on. |
snare_turns | u32 | Turns a bear / gas trap holds you; 0 otherwise. |
A trap entity carries Name, Renderable, Position, Trap, Hidden. It is never an Item and never a tile type.
Mechanic: apply_trap_effect in models/src/traps.rs. That match has no catch-all, so a new TrapEffect variant will not compile until it has an arm. A snaring trap reads its duration straight off the row, so it stays a one-file change.
Reveal style is rolled per trap at spawn, equal odds, not per row: Sight / Adjacent / Triggered.
A sprung trap gives out TRAP_BREAK_CHANCE of the time — the entity despawns and the log says "The dart trap breaks!" when the player can see it. The bear trap is exempt: it always bites once and is gone, quietly.
A trap set off from a distance — a missile that lands on it, a wand’s blast that covers it, another burst chaining into it — has nobody standing on it to bite, so it bursts instead: detonate_trap deals TRICK_SHOT_DAMAGE_DICE d TRICK_SHOT_DAMAGE_SIDES (armour-ignoring) over the TRICK_SHOT_RADIUS around its tile and then runs the mechanic once per creature caught. Dials: constants::traps.
Trick shots — what a landing missile can set off
traps::detonate_at(world, pos, shooter) is the whole of it, called by items::throwing::resolve_throw for every throw, whatever was thrown. It returns a TrickShot saying what went off, or None. shooter is Option<Entity> because a chain reaction can run past the last thing its author was around for.
A missile stops on the first creature in its way, so the tile handed to detonate_at is that creature’s tile: hitting a monster standing on a trap, a coin or the relic sets the thing underneath off with the blow. That is what the renderer’s magenta cell is advertising (see rendering.md, layer 9a).
Only a trap the player has found can be aimed at. detonate_at skips one still carrying Hidden and returns None — the shot lands and nothing happens. Lining a shot up on a mechanism nobody has discovered would be the dungeon setting off its own trap on the player’s behalf. Blasts and chain reactions are under no such rule.
| On the tile | Reach | Damage | Follow-up |
|---|---|---|---|
a Trap | TRICK_SHOT_RADIUS | the trick-shot dice | the trap’s own effect, per survivor |
a Pickup (a coin) | PICKUP_TRICK_SHOT_RADIUS | the same dice | the coin’s effect, paid to the shooter (pickups::claim_from_afar) — a red coin heals them, a gold one pays them, a platinum one makes them its promise. Worked before the burst, so the shooter’s own blast cannot take the healing back off them. No would_help gate: stepping over a coin is leaving it for later, shooting one is a decision, and a decision is allowed to be a waste |
a hero coin (PickupEffect::LearnRandomSpell) | PICKUP_TRICK_SHOT_RADIUS, then TRICK_SHOT_RADIUS twice | the dice, once per burst | the spell, taught to the shooter, and then the ULTIMATE TRICK SHOT below. Spent like any other coin |
the Amulet (the Element of Yoord) | PICKUP_TRICK_SHOT_RADIUS, then TRICK_SHOT_RADIUS twice | the dice, once per burst | see below. The relic is never destroyed, moved or spent |
A shot that sets anything off with something other than ammunition — a dagger, a potion, somebody’s spare ring — logs “Very clever.” Firing an arrow into a trap is what arrows are for; doing it with the rest of your kit is a choice.
A shot that kills two or more ends the turn on the combo line, and the log folds down to the shout and that line (GameLog::fold_into_trick_shot): the deaths, the dropped gear, the coins and “Very clever.” between them are dropped. A coin shot for nothing (a red one at full health) pays nothing and says nothing.
The ULTIMATE TRICK SHOT (traps::ultimate_burst) is what the relic does when a missile comes down on it (traps::ultimate_trick_shot), and what a hero coin does when one comes down on it — the one pickup worth shooting for the shot rather than the payout. The coin, unlike the relic, does not survive saying it: it teaches the shooter its spell, logs “The hero coin gives up everything it knows at once.”, and is gone. The shape is one wide burst where it lies, then a second burst centred on every creature that one caught, then a third on one of them (the first in reading order). Each can catch somebody the last one missed. All three burn in BlastPalette::Ultimate — white through magenta to dark magenta, the only blast no wand can produce — and the primary leaves a Smoke puff over every tile it covered. Only the first burst shouts — BAM!, or WHY! when the player is standing in their own blast; one shot is one trick shot however many times it goes off.
Chain reactions. Everything that blows up, flies or zaps ends by setting off everything in its own footprint that a shot could have set off (traps::chain_react), whatever the footprint’s size: a blast’s disc (traps::burst, wands::elemental_blast), a bolt’s line (the bolt wands, Force Lance), the one tile of a Sting or Thunderbolt, the victims’ tiles under Frost Nova and Circle of Death. Traps first, then coins, then potions, then ice cubes (a fire blast scorches them), and the author is carried through: a coin your chain reaches still pays you. A trap nobody has found is a valid link — the Hidden rule is about aiming, and a blast rolling over a tile does not have to know what is buried in it. The chain always ends, because every link is despawned before its own burst opens, so nothing is ever a link twice. The Element of Yoord is deliberately not a link: it is never spent, and a burst that reached it would answer itself forever.
Every link of a chain opens on the same frame, and each link’s dead fall with it: the whole chain goes off at once, as one blast. The only wait is the missile’s flight, which holds the batch until it lands; whatever the shot sets off stays on the floor until then.
A wand’s blast sets off everything it covers, traps first and then coins (wands::elemental_blast, via traps::things_in), and the coins pay the zapper — the blast’s author is the shooter. A potion in the footprint splashes through elemental_blast in its turn, so a blast can re-enter it; the chain still ends because the potion is despawned first.
A coin set off with no author at all is simply spent: detonate_pickup takes an Option<Entity> and pays nobody when there is nobody to pay.
Damage traps are missiles: they ignore the defender’s armour die but still subtract the armour plus (total_armor_plus). Their bite also scales with depth in three bands (floors 1-4, 5-8, 9-13): each band adds a point to the arrow trap’s roll and a point to the dart trap’s permanent power drain. Dial: constants::traps (TRAP_DAMAGE_TIER_LAST_DEPTH and the per-tier steps).
The Trap, TrapEffect and TrapReveal types are defined in components.rs, not traps.rs — see components.md. The holds a trap applies (Asleep, Pinned, Rooted) are effects, in effects.rs.
DROPS — DropCategory
models/src/spawn.rs
| Field | Type | Notes |
|---|---|---|
name | &'static str | The category, not an item name. |
weight | u32 | Share of a floor’s drops. |
min_depth | u8 | Shallowest floor it drops on. |
Three further fields are function pointers filled in by the category! macro from the table’s name. Never write them by hand.
Current weights, which total 1110 from floor 3 down. Runes drop from floor 3, so floors 1 and 2 total 1060 and every share there is a little higher:
| Category | Weight | Share |
|---|---|---|
| scroll | 300 | 27.0% |
| potion | 270 | 24.3% |
| coin | 130 | 11.7% |
| armor | 80 | 7.2% |
| wand | 50 | 4.5% |
| ring | 50 | 4.5% |
| rune | 50 | 4.5% |
| weapon | 36 | 3.2% |
| ammo | 28 | 2.5% |
| launcher | 16 | 1.4% |
| treat | 50 | 4.5% |
| deck | 50 | 4.5% |
The share column is derived, not maintained.
EFFECTS — the marker registry
models/src/effects.rs
Each row pairs a stable string id with the component it attaches, and that id is what the save file stores. Rows may be reordered and retired freely; the one rule is never rename an id, the same rule a bestiary row lives by. There is no ceiling on how many effects there can be.
A row may also carry the line the player reads when it runs out of turns, in brackets after the type:
"asleep" => Asleep ["You shake off the drowsiness and come to."],
Identity effects: parts nothing can cancel. A wand of cancellation strips every effect a creature holds, except the ones whose id is listed in IDENTITY_EFFECTS in effects.rs. revoke_all removes the rest, then puts the identity ones back with their ledger entry, so they are still saved. Today that is lurk (the lurk’s body) and the four that make a dog: always_tamed, always_helper, priority_helper and faerie_on_death. The dog’s shapeshifting (shapeshift_on_kill, and the mirror hound’s mirror_on_kill) is magic, not identity: a wand of cancellation stops it. The rule of thumb: an identity effect is what a creature is, not magic it merely has. A dragon’s FireImmune stays cancellable on purpose. To make a part of a monster uncancellable, write the effect as usual (../how-to/add-an-effect.md), list its id in IDENTITY_EFFECTS, and grant it from the bestiary row (../how-to/add-a-monster.md).
| # | Effect | Meaning |
|---|---|---|
| 0 | FireImmune | Fire does nothing. |
| 1 | ColdImmune | Cold does nothing. |
| 2 | Undead | Draining passes through, healing nothing. |
| 3 | VorpalTarget | Any vorpal weapon slays it outright. |
| 4 | SeesInvisible | Sees hidden traps in view, invisible monsters, stashed items. |
| 5 | SustainsStrength | Immune to dart-trap strength drain. |
| 6 | AggravatesMonsters | Periodically wakes the floor. Passive. |
| 7 | ItemUser | Catches and wears thrown gear; reads scrolls. |
| 8 | FireArrow | Looses arrows properly (they roll their launched_die). |
| 9 | FireQuarrel | The crossbow’s half of the same bargain. |
| 10 | FireDart | The blowgun’s half of the same bargain. |
| 11 | SustainsArmor | Worn armour cannot be corroded. |
| 12 | RustsArmor | Every blow it lands eats a point of the victim’s armour plus (the aquator). |
| 13 | Sluggish | Acts one notch below its own tempo. Folded in by conditions::tempo, never written to Speed. |
| 14 | Stealthy | Unnoticed until rings::STEALTH_RANGE tiles away. |
| 15 | Regenerates | Mends one condition, or a point of drained power, on a roll. Passive. |
| 16 | Teleportitis | Jumps somewhere else on a roll. Passive. Also arms the T key. |
| 17 | Flies | Never springs a floor trap. |
| 18 | Batty | After a landed blow, hops to a random open adjacent tile. |
| 19 | Binds | Every hit clamps the victim in a bear trap’s jaws. |
| 20 | Gorgon | Petrifies whoever targets, shoots or zaps it. |
| 21 | Vampiric | Every hit drinks a point of the victim’s maximum HP. |
| 22 | Venomous | Its bite saps the victim’s base power, with no floor. |
| 23 | ScoreBounty | Its corpse pays a multiple of the usual score. |
| 24 | Splits | Cut down short of the last point, it buds a copy of itself. |
| 25 | GreenBlood | Wounds well up green. Cosmetic. |
| 26 | Freezing | A chance on every hit to paralyse the victim. |
| 27 | StealsAndFlees | Lifts something loose from the victim’s pack, uses it, and vanishes (the leprechaun). |
| 28 | StealsAndVanishes | Strips one equipped item and vanishes with it (the nymph). |
| 29 | AlwaysTamed | Any treat takes, every time (the dog). |
| 30 | AlwaysHelper | Charmed or conjured, it is the Helper, not a plain ally (the dog). |
| 31 | PriorityHelper | A Helper that never explodes to make room, and explodes the ordinary one (the dog). |
| 32 | ShapeshiftOnKill | A melee kill sometimes turns it into another random monster, once (the dog). |
| 33 | MirrorOnKill | A melee kill sometimes turns it into exactly what it killed, once (the mirror hound). |
| 34 | FaerieOnDeath | Dying, it is revealed as a faerie shapeshifter and is gone, with no gore (the dog). |
| 35 | Swims | Deep water is floor. |
| 36 | Phasing | Walks through walls and water; no diagonal rule, no room leash. |
| 37 | Cleaves | A connecting swing also lands on every other enemy next to the wielder. |
| 38 | HeavySwing | A hit that lands staggers the victim for a turn; the swing costs the wielder an extra monster round. |
| 39 | Fencer | Every attack is thrown as many times as the grant says (Grant::counted::<Fencer>(n); estoc: ESTOC_NUMBER_OF_ATTACKS). |
| 40 | Lunges | Closing the last stride of a run lands a lunge instead of a step. |
| 41 | Lurk | The lurk’s body. An identity effect: revoke_all leaves it. |
| 42 | WhirlOnMove | Stepping between two tiles beside the same enemy lands a free attack. |
| 43 | VorpalOnCondition | A hit on a target with a negative condition slays it outright. |
| 44 | TurboMagic | Damaging spells cost TURBO_MAGIC_COST_MULT times the Magic and deal TURBO_MAGIC_POWER_MULT times the damage. |
| 45 | SelfDamageOnHit | Every connecting hit costs the wielder a point of HP. |
| 46 | BuildsMomentum | Every hit builds Momentum on the weapon. |
| 47 | ShattersStone | Lands whole on a Petrified target, past stone_chip. |
| 48 | ConfusingTouch | Charged by a scroll: the next blow it lands confuses the target, then the charge is spent. |
| 49 | Bided | The spell Bide: the next attack gets BIDE_ATTACK_BONUS, then it is spent or lost. |
| 50 | Asleep | Hold: out cold, no action of any kind. |
| 51 | Petrified | Hold, but not in HOLDS: stone is the body, so it travels with its owner. |
| 52 | Pinned | Hold: cannot step, can still strike. Straining costs a turn and blood. |
| 53 | Rooted | Hold: cannot step, can still strike. Straining costs only the turn. |
| 54 | Clamped | Hold: a biter’s grip. Killing the biter frees the victim. |
| 55 | Confused | Player affliction. A share of moves (CONFUSION_STUMBLE_CHANCE) goes astray. Lifted by a staircase or cancellation. |
| 56 | Blind | Player affliction. Sight shrinks to the tile underfoot and no creature is perceptible. |
| 57 | Paralyzed | Affliction: whoever wears it loses a share of their turns. |
| 58 | MagicWard | The spell: magical hits and a blow’s riders bounce off, for the floor. |
| 59 | Detected | Drawn on the map where unseen, for the floor. The glyph does not animate or get announced. |
| 60 | Polymorphed | A species’ powers on loan (POLY): for the floor on the player, permanent on a monster, so a Helper keeps it down the stairs. The species is the grants lent beside it; the creature’s own name, glyph and numbers never change. A shape without ItemUser has no hands. Polymorphing a creature that holds it is a coin flip (SYSTEM_SHOCK_CHANCE): system shock (a monster bursts in gore, the player is left on 1 HP), or a chimeric form. |
| 61 | Polymorphitis | Turns the bearer into something else on a roll (POLYMORPHITIS_CHANCE): the polymorph a wand casts, without the system shock (polymorph_entity_with(.., false)), so a bearer already Polymorphed settles into a form instead. Passive. |
| 62 | Chimera | One of three chimeric forms (FORMS), held at most one at a time: a twice-polymorphed creature is drawn as C and named for it. Read at the point of use (chimeric_form), never written to Name or Renderable, so a staircase or cancellation ends it. |
| 63 | Typhon | The form drawn as T. |
| 64 | Echidna | The form drawn as E. |
| 65 | Protected | A rune of protection: no damage of any kind for PROTECTION_TURNS turns. Checked in apply_hit and in melee’s clamp_swing, the two places HP comes off. |
| 66 | ExplodesOnDeath | A rune of justice: dying, it bursts in one fire blast rolled off its own power die (combat::burst_on_death). |
| 67 | SustainsForm | No polymorph takes hold, and no system shock: the wand, potion, spells and the ring of polymorph all turn aside. |
| 68 | DualZap | An attack wand zaps twice for two charges (items::plan_use); utility wands, and a wand down to its last charge, zap once. |
| 69 | Spiked | Spikemail: a blow that draws blood from the wearer pricks the attacker for 1d2 straight off their HP (abilities::spike_prick, at Moment::OnStruck). |
| 70 | Charges | Shift + direction with a creature in view (2 to 6 tiles off) charges it. Nihil is born with it. |
| 71 | Vuln | Monster melee blows land 25% harder. A charge lends it for one monster phase. |
Cap components — ceilings the dice cannot beat. Folded with min, not +, because the strictest one wins. Not in EFFECTS, not bits:
MeleeCap most the bearer can deal in one melee blow (a bow: 1)
Modifier components — numbers that stack across equipped gear, folded by equipped_total::<C>(). Not in EFFECTS, not bits:
PowerDie adds to the attack die size
PowerBonus flat, added once to the damage roll
ArmorDie adds to the defence die size
ArmorBonus flat, added once to the armour roll
ThrowBonus flat, added once to anything thrown
MaxHpBonus max hp lent while worn, not folded: put on, the plus joins max and current hp; taken off, only the max drops (ring of health)
ABILITIES — Ability
models/src/abilities.rs
What an effect does on its own, one list for every moment there is.
| Field | Type | Notes |
|---|---|---|
effect | Grant | The marker that arms it — on the attacker for Moment::OnHit, on the bearer for everything else. |
when | Moment | See below. |
player_only | bool | The player’s own trick — a monster that steals or catches the weapon behind it still fights the plain way. |
action | fn(&mut World, Entity, Option<Entity>) -> bool | The mechanic, run as (bearer, other_end). Reports whether it actually did anything. |
flavour | Option<&'static str> | Logged only for the player, and only when action returned true. Write it in the second person. |
The bool action returns is what lets a row that often has nothing to do (a
ring of regeneration on an unhurt player, rolling every other turn) stay
silent instead of narrating a non-event.
Moment — one variant per moment the game has:
| Moment | Fires | Notes |
|---|---|---|
OnHit { glancing, lethal } | A blow the bearer landed | The two flags are the only gating there is: does a glancing scrape count (acid says yes, a charm that needs skin says no), and does the killing blow count (there is no point charming a corpse). Driven by combat::resolve_attack, for every hit that dealt damage, never learning what is in the table. |
EachTurn(f64) | Every turn the bearer acts, at this probability | AggravatesMonsters, Regenerates, Teleportitis and Polymorphitis are the rows; see the table for their exact odds. |
OnDamaged | The bearer was hurt and lived | Driven by helpers::took_damage. |
OnTargeted | The player turned their attention on the bearer | Fires before the blow, whether or not it lands — a gorgon’s gaze is the danger. |
There is no moment for a decision. What a creature chooses to do with its turn — a dragon’s fireball included — is its rule set’s business, not an ability’s: see agents.md.
ability_system drives EachTurn at the tail of
the turn schedule, after ai and before visibility_system — so a passive
that moves its bearer lands the jump at the top of the bearer’s next turn, and
the player acts from the new tile before anything on the floor moves again.
Adding a moment nobody has yet is a Moment variant and one arm in whatever
drives it. Adding an ability at a moment that already exists is one row.
ConfusingTouch arms an OnHit row but is deliberately not in EFFECTS: it
is a condition with its own save field (saveload), not a cancellable
creature property. Grant::probe works either way — the registry is only
about save bits and cancellation.
Enchantment
Rolled by enchant_equipment for every weapon, armour and launcher, and by enchant_ring for every ring, that arrives as a floor drop. Never for a spawn_named spawn. The odds and the bonus ranges are models/src/constants.rs → loot (see constants.md).
| Quality | Odds | Bonus |
|---|---|---|
| Normal | NORMAL_QUALITY_PCT | +0 |
| Exceptional | EXCEPTIONAL_QUALITY_PCT | EXCEPTIONAL_BONUS_MIN .. EXCEPTIONAL_BONUS_MAX |
| Cursed | whatever is left | CURSED_BONUS_MIN .. CURSED_BONUS_MAX, plus a Curse tag |
| MEGACURSED | MEGACURSED_PCT_OF_CURSED% of the cursed | MEGACURSED_BONUS_MIN .. MEGACURSED_BONUS_MAX (-3 .. +0), plus Curse and MegaCurse tags |
A MEGACURSED item is stuck on for good. Remove curse, a scroll of enchantment, cancellation, an angel’s test of faith and a Five Flush all leave the Curse on it (enchantment, cancellation and the flush still change the plus). Remove curse, the pink demon, a curse merge and the deck’s shivering ring never destroy it. A suit of armour taking the killing blow is the one way out. The MegaCurse tag marks it, and equipment::lift_curse is the one place a curse comes off.
A cursed item can roll better than a clean one; it simply cannot be taken off once equipped, short of a scroll of remove curse (which destroys it) or the matching scroll of enchantment (which lifts the Curse tag and mends any minus to +0 — see constants::scrolls::ENCHANT_BONUS).
The bonus lands on whichever roll the item feeds, read off the item itself: a thing with a PowerDie gets PowerBonus, a thing with an ArmorDie gets ArmorBonus, a Launcher gets ThrowBonus. Something that is two of those would get both. Never on the die size.
Rings
A ring rolls the same three qualities at the same odds, with its own numbers (constants::rings). Only a numeric ring (RingDef::is_numeric: protection, strength, increase damage, sharpshooting) has a plus to roll, and the plus is absolute: it replaces the row’s, it is not added to it.
| Quality | Numeric ring | Any other ring |
|---|---|---|
| Normal | PLAIN_BONUS (+2, which is the row) | nothing |
| Exceptional | EXCEPTIONAL_BONUS (+3) | nothing |
| Cursed | CURSED_BONUS_MIN .. CURSED_BONUS_MAX (-3 .. +2), plus a Curse tag | a Curse tag |
| MEGACURSED | rings::MEGACURSED_BONUS_MIN .. MEGACURSED_BONUS_MAX (+0 .. +13), plus Curse and MegaCurse tags | Curse and MegaCurse tags |
A cursed ring never beats a plain one: its range stops at PLAIN_BONUS. The rows spell their number with PLAIN_BONUS, so the table and the roll cannot disagree.
Identification
models/src/identify.rs
Potions, scrolls, wands and rings are always shown by their true name — no cosmetic appearance, no per-effect knowledge to track, nothing to keep in step with the catalog when a row is added.
Identification is equipment-only: a weapon, suit of armour or launcher hides its enchantment plus and cursed status until KnownQuality says otherwise (set by wearing it, or by a scroll of identify) — and a ring, which enchant_ring curses and, for a numeric ring, gives a plus, hides both the same way. KnownQuality is per-instance — two rings of protection each rolled their own curse, so each needs its own.
A dud effect (PotionEffect::Water, ScrollEffect::BlankPaper, WandEffect::Nothing) is never a spawnable row; it only ever happens as the result of a wand of cancellation mutating a carried item in place (items/wands.rs::cancel_entity). Guarded by the_dungeon_never_generates_a_dud_as_normal_loot in models/tests/it/content.rs.
The colour palette
The save file packs a colour into one byte against this fixed list. Anything not on it draws correctly but reloads as White.
Black DarkGrey Grey White
Red DarkRed Green DarkGreen
Yellow DarkYellow Blue DarkBlue
Magenta DarkMagenta Cyan DarkCyan
Dungeon constants
The numbers a content author meets most often, and where each one is defined. The values are deliberately not repeated here — constants.rs is the only place they live, and a copy in prose is a copy that goes stale the first time somebody rebalances.
| Constant | Defined in |
|---|---|
MAP_WIDTH / MAP_HEIGHT | constants::map |
FINAL_DEPTH — the floor holding the relic | constants::progression |
DUNGEON_LORD_PATIENCE — turns on one floor before eviction | constants::progression |
STACK_LIMIT — most of one item per pack slot | constants::items |
PACK_CAPACITY — most slots a pack holds at once | constants::items |
THROW_RANGE — how far you can hurl a heavy thing | constants::items |
LIGHT_THROW_RANGE — the same, for a potion, scroll, wand or ring | constants::items |
LAUNCHER_RANGE — how far a bow or crossbow carries its own ammo | constants::items |
Speed::COST — energy one action costs | components.rs, on Speed itself |
Every one of them carries a doc comment saying what changing it costs you. See constants.md for the tour.
See also
../explanation/the-feel-layer.md what each of these looks like on screen spawn-api.md the functions that read these tables cli-and-env.md flags and environment variables input-and-turn-loop.md how confusion, snares, etc. play out at the keyboard ../how-to/add-an-item.md how to add a row to one of these ../explanation/adr-0006-effects-saved-by-id.md why an effect is saved by id
Reference: input handling and the turn loop
Audience Engine developers only. There is no table here, no
row to copy — this is the engine itself: the main
loop, the keyboard, the modal stack.
Prerequisites A passing familiarity with bevy_ecs, and
`components.md` for the resources named
below (`GameLog`, `PackIsOpen`, `TargetingState`,
`AutoExplore`, `FastMove`, `TravelCursor`, `Ending`).
Status Describes `engine/src/update.rs` and the main loop
in `engine/src/main.rs` as they are in the source.
If this page and the source disagree, the source is
right and this page is a bug.
This is higher-risk ground than a content table. A wrong row in models/src/monsters.rs is a monster with too much HP; a wrong branch here is a keypress that eats a turn it shouldn’t, a modal that can’t be closed, or a run loop that spins. engine/ carries little test coverage: update.rs tests what dispatch_key decides and view.rs tests the log gate and the status tints (see “Testing” below), but the loops that poll and sleep and everything the renderer draws have none — so a mistake there is far more likely to be caught by a person playing the game than by cargo test. Change this file by reading it end to end first, and test by hand: walk into a wall confused, open the pack mid-throw, run a Shift-direction into a monster.
The split this code sits on
models/ holds the pathfinding and state machines (autoexplore.rs, fastmove.rs) as pure functions that only read the world — no terminal, no sleeping, no turn-taking. engine/src/update.rs is the other half: it reads the keyboard through engine/src/input.rs (the one place that calls term::read/poll, so a run can be recorded and replayed), decides when a turn is spent, and drives those pure functions to completion. Neither update.rs nor view.rs names a terminal library: input reaches them as input.rs’s own Event, and engine/src/term.rs is the one file that names crossterm. Nothing in models/ blocks on input or sleeps; nothing in engine/ does pathfinding. If you find yourself wanting to add a World query that decides where to go, it almost certainly belongs in models/, not here — see the module doc comments at the top of autoexplore.rs and fastmove.rs.
The event types
pub enum Event { Key(KeyEvent), Mouse(MouseEvent) }
pub struct KeyEvent { code: KeyCode, modifiers: KeyModifiers }
pub struct MouseEvent { button: MouseButton, column: u16, row: u16, modifiers: KeyModifiers }
pub enum MouseButton { Left, Right }
These live in input.rs and are the game’s own, so update.rs matches on them without a terminal library in sight. KeyCode has the keys a recording can name (Char, F, the arrows, Home/End, PageUp/PageDown, Tab, BackTab, Delete, Insert, Esc, Enter, Backspace). KeyModifiers is one byte with Shift 1, Control 2 and Alt 4, the same bits a recording holds; a recording line with any other bit set (Super, say) decodes with that bit dropped.
term::read is where the terminal’s events become these, and where the filtering happens. Only a key press and a Left or Right mouse down get through: a release, a repeat, a resize, pointer motion, a middle click and a key with no KeyCode (Caps Lock, a media key) return None and are never logged. That is why no handler in update.rs or view.rs checks a key’s kind. A click comes back in grid coordinates, with the -c centering offset of the frame on screen taken off; a click outside the frame is dropped there too.
The main loop
engine/src/main.rs, fn main
%%{init: {'theme':'base','themeVariables':{
'primaryColor':'#20242b','primaryTextColor':'#d7dae0',
'primaryBorderColor':'#5c6370','lineColor':'#8a8f98',
'fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace',
'fontSize':'13px'}}}%%
flowchart LR
A["A. player step<br/><i>blocks for a key,<br/>queues the action</i>"]:::hero
B{"turn<br/>spent?"}:::hero
S["schedule.run<br/><i>the whole turn</i>"]:::cold
B2["B2. particles<br/><i>blocks</i>"]:::magic
B3["B3. magic map<br/><i>blocks</i>"]:::magic
C["C. render"]:::cold
C1["C1. shake<br/><i>never blocks</i>"]:::magic
C2["C2. pace"]
D{"D. ending?"}:::peril
E["death / victory<br/>screens"]:::peril
A --> B
B -- yes --> S --> B2
B -- no --> B2
B2 --> B3 --> C --> C1 --> C2 --> D
D -- no --> A
D -- yes --> E
classDef hero fill:#3a3418,stroke:#d7ba4a,color:#e8dfa8
classDef peril fill:#3a1f1f,stroke:#c05050,color:#f0c8c8
classDef magic fill:#2f2038,stroke:#a86fc0,color:#e6cdf0
classDef cold fill:#17323a,stroke:#4aa3c0,color:#bfe4f0
One Schedule, run once per turn, in this fixed order:
player_action_system -> smoke_system -> tick_effects
-> reveal_mimics -> spell_system
-> item_system -> throw_system -> ai -> trap_system
-> equipment_effects_system -> combat_system -> reaper_system
-> dungeon_lord_system -> ability_system -> sink_system
-> visibility_system -> score_turn_system
ai keeps the clock, the gates and the hands; what each mob decides to do is its rule set’s (agents.md). A mob off the player’s view does nothing, unless it is aggravated or a Helper.
reveal_mimics runs before ai: a xeroc’s disguise falls away the instant the player is standing next to it, so the same turn that happens, ai already sees the plain Ambush monster underneath and can lash out. spell_system runs before ai: an active spell (Z, or Alt+Q/W/E/R) spends its Magic cost and resolves before monsters get their response. This matches ordinary movement, which player_action_system applies as the schedule’s first step. A damaging spell only zeroes its victim’s HP — reaper_system sweeps the body at the far end of the turn — so ai skips any mob already at 0 HP rather than letting a corpse take a parting shot on its way out.
Everything the player does resolves before ai does. A step, a blow, a lunge or a struggle is planned when the key is handled (handle_movement_input → models::queue_step) and applied by player_action_system, the first step of the schedule (PlayerActionQueue). A spell (SpellQueue), a used item (UseQueue) and a throw (ThrowQueue) are drained by spell_system, item_system and throw_system, all of them ahead of ai. Taking the stairs (queue_stairs), dropping an item (queue_drop), a willed teleport (queue_willed_teleport), a charge (queue_charge) and a reach attack (queue_reach_attack) are queued the same way, and wearing or wielding goes through the use queue. A refusal (not on the stairs, cursed gear, a blocked charge) is logged at the key and spends nothing. None of those queues is ever filled by anything but the player, so nothing of the dungeon’s own is hurried along by the order. Monster attacks are the other side of it: ai fills AttackQueue and combat_system drains it after, which is why that one stays where it is. combat_system drops a queued blow whose two sides no longer touch (still_in_reach): a trap or a swap since ai queued it may have moved one of them, or put the far side of a doorway corner between them.
item_system runs before ai for the same reason. It also runs after spell_system: the two drain different queues and a turn fills at most one, so the edge changes no outcome, but it leaves the schedule with one order instead of two the executor may choose between. A zapped wand is aimed at the dungeon as it stood when the key was pressed; resolved after ai, it would read a tile the target had already walked off. The bolt wands would hide it (a bolt sweeps a line, a blast a disc, so they still catch somebody), but every wand that reads one exact tile — teleport away, teleport to, polymorph, haste, slow, cancellation — would report finding nothing there while the reticle sat on the monster. throw_system runs before ai for the same reason: aimed at the empty tile in front of an approaching orc, the dagger would arrive after the orc had stepped onto it and hit one it was never thrown at. Pinned by a_zap_lands_on_the_tile_the_player_aimed_at_not_the_one_the_target_left and a_throw_lands_where_the_floor_was_when_the_player_let_go in update.rs — which is also why the schedule is built by turn_schedule() rather than inline in main: a turn order with load-bearing edges needs something able to run it.
One edge is absent on purpose: throw_system is not ordered after trap_system. A shot that comes down on a trap sets the trap off through detonate_at, inside the throw’s own resolution, not by waiting for the trap step.
ability_system sits second-to-last on purpose. A passive that merely happens to you can roll anywhere; one that moves you cannot. Rolled after ai, a ring of teleportation’s jump lands at the top of the player’s next turn — they see the new tile and act from it before anything on the floor moves again — and there is still a visibility pass and a render left in the turn to show it to them.
score_turn_system is dead last, after visibility_system, because it is the one step that must never run early: it totals the turn’s kills with the combo multiplier and pays them out in one go, and a multiplier applied to a pile that is still growing is not a number anyone could read.
Each pass through while world.resource::<GameState>().is_running:
- Advance the game. A fast-move run in progress (
FastMove::active) resolves entirely insidefast_move_run— it steps the schedule itself, turn by turn, without repainting. Otherwise, one player step is taken (seeplayer_stepbelow); if it consumed a turn,schedule.runfires once. - Play any queued animation.
view::play_particlesthenview::play_magic_map— both no-ops unless something armed them this turn. Both block: input is frozen for the length of the effect, and a keypress skips to the end and is swallowed. Which is why the--MORE--prompt is held back while particles are playing (log_panel, inrendering.md): a prompt up during an animation asks for the very keypress that skips it. - Render.
view::render. - Let the screen shake settle.
view::play_shake, and this one is the odd step out: it does not block. It animates only while nothing is waiting to be read, and the moment a key arrives it settles the map and returns leaving the key unread for step 1 of the next pass. That is also what guarantees the map is never left skewed while the loop blocks — the shake either runs out here or is settled here. A no-op unless a turn armed one (and-nshakemeans none ever is); seerendering.md, “The screen shake”. - Pace it. A 35ms sleep per step while auto-exploring, a 90ms sleep per turn while the player is incapacitated (asleep), so both read as time passing rather than a freeze or a blur.
- Check the ending.
Ending::player_deadorplayer_wonbreaks the loop into the death or victory screens.
fn player_step(world: &mut World) -> std::io::Result<bool>
One player-side step, in priority order: an AutoExplore tick, else a TravelCursor tick, else a blocking read of the player’s own move via process_input_and_update. Returns whether a turn was spent — the main loop only runs the schedule (lets monsters act) when this is true.
The modal stack
%%{init: {'theme':'base','themeVariables':{
'primaryColor':'#20242b','primaryTextColor':'#d7dae0',
'primaryBorderColor':'#5c6370','lineColor':'#8a8f98',
'fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace',
'fontSize':'13px'}}}%%
flowchart LR
P["process_input<br/>_and_update"]:::hero
G1{"--MORE--<br/>pending?"}
G2{"asleep, or<br/>paralysis?"}:::peril
K(["read a key"]):::hero
DK["dispatch_key"]:::hero
M{"--MORE--<br/>up?"}
X{"x / X closed<br/>a modal?"}
Q{"quit prompt?"}:::peril
T{"aiming?"}:::magic
I{"pack open?"}:::magic
MV["handle_movement_input"]:::hero
F(["turn forfeited"]):::peril
N(["no turn"])
P --> G1
G1 -- yes --> K
G1 -- no --> G2
G2 -- yes --> F
G2 -- no --> K
K --> DK --> M
M -- yes --> N
M -- no --> X
X -- yes --> N
X -- no --> Q
Q -- yes --> N
Q -- no --> T
T -- yes --> N
T -- no --> I
I -- yes --> N
I -- no --> MV
classDef hero fill:#3a3418,stroke:#d7ba4a,color:#e8dfa8
classDef peril fill:#3a1f1f,stroke:#c05050,color:#f0c8c8
classDef magic fill:#2f2038,stroke:#a86fc0,color:#e6cdf0
classDef cold fill:#17323a,stroke:#4aa3c0,color:#bfe4f0
pub fn process_input_and_update(world: &mut World) -> std::io::Result<bool>
pub(crate) fn dispatch_key(world: &mut World, key: KeyEvent) -> std::io::Result<bool>
The split between the two is the line between “needs a keyboard” and “does not”. process_input_and_update owns the three things that can consume a frame with no key read at all, and the blocking read(); everything after that key arrives is dispatch_key, which is a pure state machine over (world, key) and is tested as one — see “Testing” below.
Before reading a key at all, three things can consume the frame without one: a pending --MORE-- prompt (only Space/Enter clears it; every other key is swallowed), player_incapacitated (asleep — in gas, or in a scroll of sleep that turned in your own mouth: the turn is forfeited outright with no key read; a bear trap or a scroll’s hold does not forfeit the turn, since either only blocks movement, not the whole turn — see “Movement and attack” below), and paralysis_forfeits_turn (a potion of paralysis: the coin is flipped once per turn, here, before any key is read and never while a menu, prompt or reticle is open, so a lost turn is a turn the monsters get and the player does not, rather than a swallowed keystroke; a forfeited one holds the screen INCAPACITATED_PAUSE_MS so it reads as time passing).
Right before that read, engine::ready::signal_ready writes the ready signal when NIHILURK_READY is set (cli-and-env.md), so a script driving the game knows it now waits on a key. travel_cursor_step and wait_for_key (the death and victory screens) do the same before theirs. The reads that only let a key skip an animation or stop a walk (drain_input, interrupted, the shake, walk_interrupted) do not: the game is not waiting there.
Once a key is read, x and X are checked first, everywhere: they are the universal escape hatch, closing whichever modal is open (close_all_modals, the quit prompt included) and returning to plain movement with no turn spent. On the bare map close_all_modals reports nothing to close, so x falls through to the weapon swap and X does nothing. After that, exactly one of these contexts owns the keypress:
| Context (checked in this order) | Resource | Handler |
|---|---|---|
| Key list (F1) | HelpMenu.open | dispatch_key itself: any key closes it, nothing else runs |
| Item Info popup | ItemInfo.open | dispatch_key itself: any key closes it, nothing else runs |
| “Really quit?” | QuitPrompt.open | answer_quit_prompt |
| Aiming (a throw or a ranged use) | TargetingState.active | handle_targeting_input |
| Pack open | PackIsOpen.open | handle_inventory_input |
| Walking the map | (default) | handle_movement_input |
They nest one level deep only: the pack can open the aiming reticle (Throw, or a Use that needs a target), but the reticle itself has no modal under it. The quit prompt is raised only from the map, so it never has anything under it either — it is first in the list because while it is up, that question is the only one on the table. y quits, n / Esc cancels (and so do x / X, upstream), every other key is ignored rather than guessed at.
Movement and attack
%%{init: {'theme':'base','themeVariables':{
'primaryColor':'#20242b','primaryTextColor':'#d7dae0',
'primaryBorderColor':'#5c6370','lineColor':'#8a8f98',
'fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace',
'fontSize':'13px'}}}%%
flowchart LR
S(["a step"]):::hero
C{"confused?"}:::magic
W{"wall?"}
DG{"diagonal<br/>cut?"}
MB{"a mob<br/>there?"}:::peril
SN{"snared?"}:::peril
MV["move + tag<br/>EntityMoved"]:::hero
PU{"an item<br/>there?"}:::magic
AT["resolve_attack"]:::peril
TH["bear-trap thrash"]:::peril
T(["turn spent"]):::hero
F(["free"])
S --> C -- "CONFUSION_STUMBLE_CHANCE" --> W
C -- no --> W
W -- yes --> F
W -- no --> DG
DG -- "not allowed" --> F
DG -- ok --> MB
MB -- yes --> AT --> T
MB -- no --> SN
SN -- bear --> TH --> T
SN -- hold --> T
SN -- no --> MV --> PU
PU -- yes --> T
PU -- no --> T
classDef hero fill:#3a3418,stroke:#d7ba4a,color:#e8dfa8
classDef peril fill:#3a1f1f,stroke:#c05050,color:#f0c8c8
classDef magic fill:#2f2038,stroke:#a86fc0,color:#e6cdf0
classDef cold fill:#17323a,stroke:#4aa3c0,color:#bfe4f0
fn queue_step(world: &mut World, dx: i16, dy: i16) -> bool
The single path every step and every melee attack goes through (auto-explore, fast-move and the plain arrow keys all call this). It calls models::plan_step, which decides what the step will do and changes nothing but the confusion roll, and queues the result as a PlayerAction::Step. It returns whether the turn is spent, the caller’s cue to run the schedule; player_action_system then does the stepping. Checked in order, each one able to end the attempt:
- Paralysis — not checked here at all: a potion of paralysis eats its turns upstream, in
process_input_and_update, and drops the player’sSpeedtoSlowfor the ones it leaves. By the time a step reachesqueue_stepthe turn is the player’s to spend. - Confusion stumble (
maybe_stumble) — whileConfused, a coin flip hijacks the step into one of the eightSTUMBLE_DIRSat random, logging “You stumble foolishly.” A stumble into a wall still burns the turn; a deliberate wall-bump does not. 1.5. An estoc’s lunge (models::try_lunge) — checked before the wall/diagonal gates below, since it targets the tile past the one those gates would otherwise judge. Self-checksLunges(lent to the wielder while an estoc is in hand) and the geometry: the near tile (new_x, new_y) open and unoccupied, aMobon the tile past it in the same direction. If both hold, it resolves a guaranteed triple-damage strike (combat::resolve_lunge) and carries the player into the near tile itself, taggingEntityMoved— a full alternate ending to the function, never falling through to steps 2+. A no-op, returningfalse, for anyone not wielding one. - Wall.
Map::blocks. - Diagonal cut.
Map::diagonal_step_ok— a diagonal step must connect two tiles of the same kind, so you can’t cut a doorway corner or squeeze from a corridor into a room diagonally. - A
Mobon the target tile — attacks instead of moving (models::melee_attack, which resolves the plain opposed-roll swing viaresolve_attackplus whatever a wielded weapon lends on top of it — an estoc’s second strike, a battle axe’s cleave onto every other adjacent monster — each self-checked against the weapon’s own marker, so this call site never has to know either trick exists). AnIceCubeis the other exception: the player kicks it (models::kick_ice_cube) — it homes on theauto_fight_targetfor cold damage, or flies off the way it was kicked, and shatters either way, spending the turn. The player’sHelperis the exception: walking into it is a step, and the two trade places at step 6. 4.5. Momentum resets (models::reset_momentum) — reached only when step 4 did not fire: a rapier’s built-upMomentumis done the moment its wielder does anything but keep swinging it. - Something holding the player (
Pinned,Rooted) — an adjacent swing above still lands as an attack (step 4), but a plain step does not. A bear trap makes itbear_trap_thrash: a wasted turn, a scratch of damage, blood. A scroll’s hold costs the turn and nothing else. Seedocs/*traps*for the trap itself. (Nothing in the dungeon holds the player today — no monster has a viewshed to read a scroll of hold monster by — the branch is there so that stays true if one ever does.) - The move. Position updates, the player’s
Viewshedis marked dirty, andEntityMovedis tagged on the player sotrap_systemchecks the new tile. AHelperthe player walked into takes the tile they left, taggedEntityMovedtoo. 6.5. A chain-sickle’s whirl (models::try_whirl_attack) — self-checksWhirlOnMoveand looks for a monster adjacent to both the tile just left and the tile just reached (a step taken alongside an enemy), landing a freemelee_attackon it if one qualifies. - Pickup. An
Itemon the landed tile is stowed (models::stow) — which can merge into an existing quiver stack, leave part of a pile behind if the pack is full, or refuse outright (“Your pack is full.”). AHidden(invisibly stashed) item announces itself the instant it’s stepped on.
Every one of steps 1.5–5 can return early; only reaching the move at step 6 (or a hijacked stumble into a wall) consumes a turn. Every weapon trick above lives in models::combat/models::abilities, self-checking the marker it answers to — plan_step never mentions Fencer, Cleaves or WhirlOnMove by name, the same way it never mentions a ring.
A greatclub’s own trick (HeavySwing) doesn’t live here at all: it fires as an on-hit ability inside resolve_attack itself (staggering the victim one turn, Asleep), and sets ExtraMonsterRound, a resource models::ai::ai checks on its next run to hand the floor one extra monster round on top of whatever the player’s own tempo already bought.
A reach weapon’s own strike (a bardiche, a whip) does not go through queue_step at all — v opens the aiming reticle instead (see “The aiming reticle” below) and queues the strike (models::queue_reach_attack) for player_action_system to resolve through models::resolve_reach_attack.
The keyboard on the map
keymap.txt remaps keys here. remap_key (update.rs) runs at the top of handle_movement_input and handle_targeting_input and nothing else. The menus read a-z as the labels they print, so a remap there would make the label lie. dispatch_key handles Ctrl+C, --MORE-- and the x escape hatch before either handler, so a bad keymap cannot lock the player in. The KeyMap resource is models::KeyMap.
Movement is vi keys, arrows and the numpad, eight ways, plus Shift+direction to run (run_direction). WASD is not a movement scheme any more, shifted or otherwise: those letters are commands.
Esc quits, from the bare map only. Every modal takes Esc first, so mashing it out of a menu never reaches the prompt. Quitting is Esc through the prompt, or Ctrl+C without one. Q and X no longer quit.
| Key | handle_movement_input does |
|---|---|
i a t d e q r z w W P | open_pack(world, PackMode::…) — see below |
o / O | auto-explore / travel cursor |
A | toggle_auto_pickup — flips AutoPickup::enabled, logs which way it landed, spends no turn |
f / Tab | fire the wielded launcher / auto-fight; with a Returns weapon in hand (models::wielded_returner), both throw it instead (aim_returner / ranged_auto_fight) |
v | begin_reach_attack — gated on models::wielded_reach_weapon, opens the aiming reticle out to the weapon’s own Reach (TargetingState.reach_attack); a wielded Returns weapon opens a throw reticle instead (aim_returner) |
T | undocumented on purpose. models::queue_willed_teleport, applied by models::willed_teleport: with Teleportitis on the player (a worn ring of teleportation) and at least rings::TELEPORT_MAGIC_COST magic points, it spends them and jumps. Every other path returns false and logs nothing at all — no refusal, no hint the key exists. Keep it out of MANUAL.md. |
; | begin_look — opens the reticle in look mode (see below). Not L: that is the shifted vi key for east and run_direction claims it first |
Z | begin_spells_menu — the spells list, rows lettered a-d. The slower way to an active spell. |
Alt+Q W E R | fire_spell(world, slot) for slots one to four, checked before the bare letters so Alt+q casts while q still quaffs. Any other Alt-held key does what it does bare. Shift+digit was rejected for these: it is layout-dependent |
> . / < , | stairs, or travel to them |
F1 | open HelpMenu, the key list. No turn |
F2 | toggle CommandBar.hidden, the yellow bar. No turn |
x | models::queue_swap_weapon — wields the next known, uncursed Slot::Hand item in pack order across the melee/Launcher divide, as PlayerAction::SwapWeapon (one turn). Refuses, with a log line and no turn, when there is no such item or a cursed weapon is in hand. Reached only with nothing open; otherwise it is the escape hatch above |
Esc | raise QuitPrompt — the “Really quit?” modal. With time stopped it takes two yeses; the main loop saves only on save_on_time_edge, never while time is stopped, and skips the exit save then too |
| Ctrl+C | never reaches this table. dispatch_key claims it first, above the --MORE-- gate, so it quits from every context — a menu that selects rows by letter would otherwise read it as picking row c |
Adding a command key is a row in that match and (if it opens the pack) a row in PackMode. Two things claim keys before that match ever runs, and both have silently eaten a command before:
run_direction, called at the top ofhandle_movement_input, ownsH J K L Y U B Noutright. A command on any of those eight is dead code — this is what happened toLfor look.- the
x/Xescape hatch indispatch_key, which fires from every context including the map. - Ctrl+C, taken at the very top of
dispatch_key, above everything. Right after it, any other key held with Ctrl and without Alt is dropped, so no command can be bound to a Ctrl chord by accident (Windows reports AltGr as Ctrl+Alt, which is why Alt exempts a key). - Alt+
qwer, inhandle_movement_inputbefore the bare letters, so those four keys mean two things depending on Alt. Terminals deliver Alt as Esc plus the letter, and crossterm calls it Alt only when both bytes arrive in one read.
Check a new key against the menus’ own letters too: pack rows run from a for PACK_CAPACITY rows and spells rows from a for SPELLSET_CAP, and both menus’ letter arms claim every lowercase key that isn’t already navigation. In a menu, navigation is read before the letter, so j and k can never select a row.
The pack and the action modal
Eleven keys open the pack, each in a PackMode (models/src/pack.rs) that decides the title, which rows are shown (pack_rows) and what picking one does (PackMode::action). i is the only one that asks afterwards; the rest carry their own verb and commit on the spot, closing the pack as they go. open_pack refuses with the mode’s own line (“You have nothing to read.”) rather than opening an empty box.
Rows are backpack indices, not row numbers, everywhere — PackIsOpen::selected, pack_rows, commit_item_action’s item_idx, and the letter draw_inventory paints. That is what keeps an item’s letter the same in every menu; step_row walks the cursor between the admitted indices, skipping the rest.
The action modal (run_action_modal, PackMode::Browse only) is a fixed four-row menu — ItemAction::MENU — that dispatches to commit_item_action:
ItemAction | What happens | Spends a turn? |
|---|---|---|
Use | use_or_aim: a plain item queues onto UseQueue immediately; a ranged one (has Ranged, and isn’t a self-targeted wand like light) instead reopens the aiming reticle; a treat or ammunition goes back in the pack with a “for throwing” line (models::use_refusal). | Only the immediate case |
Throw | aim_throw: opens the aiming reticle, unless throw_refusal objects (the Element of Yoord, cursed worn gear). | Never here — the throw itself is queued from the reticle |
Drop | queue_drop: refused by drop_refusal (cursed and equipped) with the item left in the pack; otherwise queued, and player_action_system unequips it and places it on the floor. | Yes, on success |
Info | Fills ItemInfo from models::item_info and opens its popup (draw_item_info); any key closes it. The text is the name, the dice and reach its components carry, strings::item_blurb(id) if the row has an authored line, and — only when known_quality — the enchantment plus and strings::cursed_explained. Add a blurb as an arm in item_blurb. | Never |
take_pack_item / return_to_pack move an item out of the Backpack and back by index — used so an item earmarked for aiming can sit “in flight” without being in either the pack or on the floor while the reticle is up.
The aiming reticle
The reticle opens on the closest visible monster within its own reach (nearest_mob, ties broken in reading order), so one monster and one wand is z, pick, Enter with no cursor keys in between; with nothing in view it opens on the player. Look mode is the exception and always opens on your own tile — its cursor is your attention (meet_its_eyes fires OnTargeted on every move), and snapping it onto a medusa would petrify you for pressing L.
TargetingState holds which of an item, an active spell, a plain look, or a reach weapon’s own strike the reticle is for (exactly one of item / spell_effect / looking / reach_attack is meaningfully set — reach_attack is the one exception that still carries item, since the weapon never leaves the wielder’s hand), plus whether it’s a throw, and the cursor. spell_target_cursor only allows the cursor onto a tile that is both currently visible and within aim_range — for a throw, whatever models::throw_reach says: LAUNCHER_RANGE for ammunition matched to the launcher in hand, LIGHT_THROW_RANGE for a potion, scroll, wand or ring, THROW_RANGE for everything heavier; the spell’s own SpellDef.range, the wielded weapon’s own Reach for a reach attack, the item’s own Ranged.range for a zap, a look’s own reach the width of the map (the in_view check does the real bounding), 8 as a fallback. Tab (cycle_target) snaps the cursor to the next monster or item in view instead of nudging it one tile. Confirming (fire_at_target) refuses a shot at the player’s own tile (“Great idea! But no.”) for every purpose except looking — that one is allowed on your own tile, and spends no turn at all. A reach attack resolves in place (models::resolve_reach_attack) and never touches the pack; otherwise it removes the item from the pack and pushes a WantsToThrow or WantsToUse onto the matching queue, or a WantsToCast onto SpellQueue, for throw_system / item_system / spell_system to resolve next schedule run. A throw of a stacked item (arrows) goes through models::draw_one first, which splits one unit off and leaves the rest in the pack slot.
; opens the reticle in look mode; every cursor move (arrows or Tab) reads the tile out loud through announce_look rather than waiting for Enter — ; then Tab Tab Tab walks everything in view. ? on the look reticle, like a look click, opens the Info popup on everything on the tile (open_tile_info): each thing’s name and models::tile_info (an item’s Info lines, a creature’s health, attack, defence and speed, plus the hits each side needs to kill the other from mean_blow, and what a trap does), then what a creature has on. Bare floor opens nothing. On a monster, it also logs one line, "Beware: flying; spellcasting.", listing each notable ability, immunity or on-hit trick it carries. Bare floor logs nothing. The phrases come from models::dangers_of, which reads the beware field off each EFFECTS row, plus the creature’s spellbook and launcher — the engine crate names no markers of its own. Every creature is a they, whatever it is.
Running, auto-explore, and travel
Three engine-owned loops replace a single process_input_and_update call while they’re active; each is a thin driver around a pure planner in models/ (fastmove.rs, autoexplore.rs):
| Loop | Armed by | Resource | Planner | Stops on |
|---|---|---|---|---|
| Fast move (“run”) | Shift+direction | FastMove | fast_move_plan, then travel_step/straight_step each step | keypress, a monster in view, a message logged, junction/target reached, FAST_MOVE_STEP_CAP |
| Auto-explore | o | AutoExplore (target: None) | explore_step | same, plus “nowhere left to explore” |
| Travel | >/< to a known but distant staircase, or the O cursor | AutoExplore (target: Some(tile)) | travel_step | same, plus arrival |
| Travel cursor | O | TravelCursor | (none — it’s just a cursor; < > m/M i/I jump) | Esc/O/x/X, or Enter to commit into a travel AutoExplore |
explore_step heads for the nearest goal every step: a frontier tile, or a spotted item it judges worth the walk (see known_item_tiles), ranked together by path length in one nearest_open_tile search, with ties going toward the still-unseen downstairs. It remembers no destination between steps, so a player who moves nearer another goal goes there. move_player’s own pickup-on-arrival logic (see above) does the actual stowing.
detours_for_loot is what can call the beeline off wholesale, and it is the A toggle (AutoPickup::enabled) and nothing else. Whether a particular item is worth walking to is asked item by item, in known_item_tiles → worth_the_walk: a coin the player cannot use yet is skipped (items::would_help), and so is anything needing a pack slot once the pack is at PACK_CAPACITY. That per-item gate is not politeness — without it, a full pack walks to an item, is halted by “Your pack is full.”, and beelines to the same item on the next o, so the floor never gets explored. A coin needs no slot, which is why a full pack no longer calls the whole detour off.
The player can also turn an item down, and worth_the_walk skips what they have (LeftBehind, a transient resource like AutoExplore, never saved): an item they dropped (apply_drop → leave_behind), and an item they spotted in a room and walked out of without (apply_step → note_room_step). A room is the room floor 4-connected to the player’s tile. A doorway or water counts as neither in nor out, so a step into the doorway and back turns nothing down. A step o took itself turns nothing down either: the walk passing an item on its way to a nearer frontier has not refused it. Picking an item up (take_back) and a new floor (forget_left_behind, from transition_level) clear it.
Fast move (fast_move_run) is the odd one out: it runs its entire walk inside one call, stepping schedule.run itself between moves and never repainting until it returns, so a run reads as a single jump rather than an animated walk. Auto-explore and travel instead take one step per player_step call (auto_explore_step), returning to the main loop’s normal render-and-pace cycle after each — which is why auto-explore visibly walks while a run visibly jumps.
All three refuse to start while confused or with a monster in sight — except that a Shift run from a body with Charges (nihil) toward a creature in the pressed direction’s wedge becomes a charge (models::queue_charge, applied by models::charge): one turn, the player lands beside the creature, strikes, and is Vuln for the monster phase that follows — and both auto_explore_step and fast_move_run drain pending input before every step through walk_interrupted, stopping on the first event and swallowing it — otherwise it would also be read as a move on the next frame. Only a key press or a Left or Right click ever gets that far (see “The event types” below), so a right click’s own release cannot cut short the travel it started.
Mouse (-mouse only; capture is off otherwise). process_input_and_update and travel_cursor_step both pass an Event::Mouse to dispatch_mouse, which maps the grid cell to a tile with tile_under (MAP_TOP removed; term::read took the centering offset off already). Left = look (describe_target + open_tile_info + meet_its_eyes, no turn), or move the reticle / O cursor. Right = travel (O cursor’s commit via confirm_travel_cursor), or on the reticle move then fire_at_target. Unseen tiles, menus and --MORE-- ignore it. Tests: update.rs, “The mouse”.
travel_cursor_step is different again: it isn’t a walk at all, just a blinking highlight the player steers with arrow/vi/numpad keys over already-revealed ground (sliding along one axis when the diagonal neighbour is still unseen), gated to 400ms polls so the blink phase flips on a timeout. It runs its own loop outside process_input_and_update, so the universal escape hatch never sees its keys — x / X are handled in its own cancel arm instead, which is the only reason closing it works at all. Enter commits the tile to a travel AutoExplore via nearest_reachable if the chosen tile isn’t itself walkable.
Testing
engine/src/update.rs carries a #[cfg(test)] mod tests, and what it covers is everything dispatch_key decides — none of which needs a terminal, because none of it is downstream of read():
- The
--MORE--gate. No key but Space/Enter does anything while messages are waiting; acknowledging drops exactly the messages the panel showed, not all of them and not one; and the gate outranks the escape hatch, so a player mashingxcannot lose the line telling them why they are about to die. - The
x/Xescape hatch. Either closes whichever modal is open. A barexon the map is not swallowed: it swaps weapons. - The quit prompt.
y/n; every other key ignored rather than guessed at; it outranks a movement key;Esccloses one, and raises one from the bare map. - The keyboard. A numpad digit moves identically to its vi-key equivalent, and Shift+numpad reads as a run direction like Shift+arrow.
view.rs has no tests of what a frame looks like, on purpose — see rendering.md. It pins only the log gate and the help-key remap. What is exercised only by playing the game: everything the renderer draws, and the run / travel / auto-explore loops (they poll and sleep). The reticle’s opening target and the tile a zap or throw lands on are tested in update.rs; its cursor loop is not. What can be tested is also tested one level down, in models/: tests/it/pack.rs pins what each menu shows, and tests/it/autoexplore.rs pins which goal the walk picks and what it leaves behind.
When you add a branch here, prefer moving the decision (what should happen) into a pure function — dispatch_key, or something in models/ — and leave engine/ holding only the parts that must touch the terminal.
See also
rendering.md the other half of the frame: view.rs
../how-to/work-with-the-ecs.md adding a system, and the borrow patterns
../explanation/ecs-in-nihilurk.md why the schedule is shaped this way
../explanation/adr-0005-aim-resolves-before-the-mobs-move.md why aiming systems sit ahead of ai
../reference/components.md the resources named throughout this page
../reference/cli-and-env.md the flags this code reads (-anim-rate, -nshake)
../explanation/code-calisthenics.md the shape this code (and all engine code) is held to
Reference: rendering
Audience Engine developers only. There is no table here —
this describes how a frame is painted, not what
appears in it.
Prerequisites A passing familiarity with bevy_ecs and
`crossterm`, and `components.md` for the
resources read below (`BloodStains`, `Corpses`,
`Smoke`, `Particles`, `Shake`, `MagicMapReveal`, `AnimRate`).
Status Describes `engine/src/view.rs` as it is in the
source. If this page and the source disagree, the
source is right and this page is a bug.
Nothing on this page is covered by an automated test, and that is the policy rather than a gap. A test of the renderer, the HUD or the frame geometry would check presentation — which glyph lands in which cell, which colour a log line comes out — so it would break whenever a layer moved and never catch something a glance at the terminal would miss. See ../explanation/the-feel-layer.md, “Why none of this is unit-tested”.
So the thing easiest to get subtly wrong here — the draw order in render, where a later layer silently covers an earlier one — is caught by you, not by CI. Change it, then run the game and look, in more than one terminal if you can.
The one thing here that is asserted is not presentation: log_panel — the rule that holds the --MORE-- prompt back while particles are playing, below — is pinned by two tests in view.rs, on a fixture of two resources. What it protects is a keypress the player loses an animation to, not which cell a glyph landed in, and it cannot be checked by looking: the frame it is wrong in looks exactly right.
The frame buffer
pub struct Screen {
cur: Vec<Cell>, // this frame, being painted
prev: Vec<Cell>, // last frame, as presented
map_shift: (i16, i16), // where the screen shake has thrown the map
...
}
type Cell = (char, Color, Color); // glyph, foreground, background
render paints the entire 80x25 grid into cur every call — nothing is incremental at that level. Screen::changed is what’s incremental: it diffs cur against prev cell by cell and yields only the cells that differ, and Term::present (engine/src/term.rs, the one file that names crossterm) turns those into a MoveTo/Print (plus SetForegroundColor/SetBackgroundColor when the colour actually changed), flushes, then calls Screen::shown to swap the two buffers. A typical turn changes a handful of cells; present writes a handful of escape sequences, not 2000. dirty_all forces a full repaint — set on the first frame, and by present whenever the centering offset changes (-c, or the terminal was resized), since a shifted viewport would otherwise leave stale cells sitting where the old frame was.
put/set_fg/set_bg/puts/hline are the only ways render touches cur; they silently no-op on an out-of-bounds coordinate rather than panicking, since a couple of callers (the targeting beam, the travel cursor) compute a tile position that can legitimately fall off the 80x25 frame.
Two coordinate systems, and the map layers use the second one. put/puts/hline take screen coordinates and are what the status line, the message log and the pack overlay paint in. put_map/fg_map/bg_map/get_map take map coordinates and are what every layer from Terrain to the travel cursor paints in; they run the coordinate through map_cell, which adds MAP_TOP (the map starts on row 1, under the status line) and map_shift (the screen shake), then clips to the map’s own rows. That is why the layers below carry no y + 1 of their own any more, and it is the one place the shake can affect what is drawn.
map_cell returning None means “not drawn”. It never clamps: a tile the shake pushes off the left edge is dropped, not piled onto column 0, and a tile pushed off the top is dropped, not smeared into the status line. Nothing is rendered to fill the gap either — the shake does not change the game’s resolution, and what leaves the viewport is simply not drawn. With map_shift at (0, 0) every map tile still maps to exactly the cell it always did, so a resting frame is the frame nihilurk drew before any of this existed. screen.rs’s module docs state the same rules.
Term::present works out the top-left offset that keeps the fixed 80x25 frame centred from the real terminal size when Term::new(true) (-c) built it; (0, 0) otherwise. Nothing above term.rs sees the offset: a click comes back from term::read already in grid coordinates, and view and update paint and read the 80x25 grid alone. Every view function that draws takes term: &mut Term and the Screen.
Screen is not in this file. It is engine/src/screen.rs, which holds the grid, the double buffer, the cell diff and the map/screen coordinate split, and knows nothing about the game. engine paints into it and term.rs presents it; what this file owns is everything that goes in the grid: the layers below, the HUD, the overlays and the playback loops.
render: the layers, in order
pub fn render<W: Write>(world, stdout, screen) -> std::io::Result<()>
%%{init: {'theme':'base','themeVariables':{
'primaryColor':'#20242b','primaryTextColor':'#d7dae0',
'primaryBorderColor':'#5c6370','lineColor':'#8a8f98',
'fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace',
'fontSize':'13px'}}}%%
flowchart LR
subgraph screen["screen coordinates — the shake does not move these"]
direction TB
H["1 · top HUD"]:::cold
L["14 · player line<br/>+ score flash<br/>+ message log"]:::cold
V["15 · pack overlay<br/>+ quit prompt"]:::cold
end
subgraph map["map coordinates — put_map, so the shake moves them"]
direction LR
T["2 · terrain"] --> B["3 · blood"]:::peril
B --> K["4 · corpses"]:::peril
K --> IT["5 · items"]:::magic
IT --> TR["6 · traps"]:::peril
TR --> SM["7 · smoke"]
SM --> AC["8 · actors"]:::hero
AC --> TI["9 · status tints"]:::magic
TI --> DE["10 · detected"]:::magic
DE --> PA["11 · particles"]:::magic
PA --> BE["12 · target beam"]:::hero
BE --> CU["13 · travel cursor"]:::hero
end
screen ~~~ map
classDef hero fill:#3a3418,stroke:#d7ba4a,color:#e8dfa8
classDef peril fill:#3a1f1f,stroke:#c05050,color:#f0c8c8
classDef magic fill:#2f2038,stroke:#a86fc0,color:#e6cdf0
classDef cold fill:#17323a,stroke:#4aa3c0,color:#bfe4f0
Painted in this order — everything after “Terrain” draws over whatever came before it on the same cell. Layers 2-13 paint in map coordinates (so the screen shake moves them); 1, 14 and 15 paint in screen coordinates (so it does not):
- Top HUD (row 0) — the dungeon’s half of the status. See below.
- Terrain — every tile with
visibleorrevealedset;revealed-but-not-visibletiles are paintedDarkGrey(the “remembered, not seen” fog look). A corridor’s walls are skipped entirely (Map::is_room_wall) so passages read as tunnels, not ditches framed on every side. - Blood overlay — recolours a tile’s existing glyph (
set_fg, notput)DarkRed, only where currentlyvisibleand never whereoccupied_by_actor— the stain marks the floor, not whatever is standing on it. Skipped entirely while the player isBlind: blood is carried by colour alone, and they have none. - Corpses — same visibility rule as blood, drawn as a
%. - Floor items — visible and not actor-occupied.
- Traps — the one exception to “unseen means blank”: a previously-discovered trap (
Without<Hidden>) still shows inDarkGreyonce out of sight, same as a known staircase. - Smoke — visible, not actor-occupied,
≈in grey; fades on its own over a few turns (seeSmokein components.md). - Actors — the player and every non-
HiddenMob, visible only. (While the player isBlindthe visibility system has already tagged every mobHidden, so nothing here has to know about blindness.) - Monster status tints — a background (
set_bg) on a visible, non-Hiddenmob that cannot fight back properly, in this precedence (status_tint):DarkBluefor oneAsleep,Cyanfor oneParalyzed,DarkGreenfor one held down (Pinned,RootedorClamped),Yellowfor one staggering (MovementType::ConfusedorFlee),Greyfor one merely slowed. The glyph repaints black over the tint. The player’sHelpertakesDarkCyaninstead, whatever state it is in. Painted after the actors so it lands under a glyph that is actually drawn. A paralysed monster loses a share of its turns in the monster round, at an otherwise normal tempo.play.sh %(see../how-to/play-through-a-pty.md) lists these tints from a live run. 9a. Trick-shot potential — a brightMagentabackground on any visible, non-Hiddenmob standing on a coin (Pickup), on the Element of Yoord (Amulet), or on a trap the player has already found (Trap,Without<Hidden>). It is an offer: a missile that stops on that creature stops on the tile under it too, and sets the thing off (traps::detonate_at— seecontent-tables.md, “Trick shots”). A trap stillHiddenis not in here, because an aimed shot passes straight over one. Painted after layer 9, so it outranks the status tints: a sleeping monster on a bear trap is worth reading as the shot rather than as the nap, and the two never disagree — a helpless monster on a live trap is both at once. - Detected things — everything carrying
Detected(a potion of magic or monster detection, or a scroll of food detection) that is not currently visible, painted in one flatDarkMagenta. Anything in view is already drawn above in its own colour; this layer is the sense, not the sight. - Particles — drawn over actors deliberately, so a hit motes over the thing it hit rather than under it.
- Targeting beam — a Bresenham line from the player to the reticle, drawn as
*in yellow, except where it crosses an actor: the actor’s own glyph is kept but recoloured yellow (or black, if the actor was already yellow-ish, so it doesn’t vanish into the beam). The reticle’s own tip additionally gets aDarkBluebackground. After the line, the blast preview: every in-view tile ofmodels::aim_footprintgets aDarkYellowbackground, the tip aside. A spell or a zap bursts on the cursor; a throw bursts whereflight_pathsays it comes down, traced pastHiddencreatures so the preview gives none away, and a wand lobbed onto open floor previews nothing because it lands whole. - Travel cursor — a background-only highlight (
set_bg), so the glyph and colour of whatever’s on that tile stay readable. - Player line (row 22), the scorekeeper’s flash (row 1, over the map’s top row), the balance gauge (
hud::balance_gauge, the same row from column 1: BALANCE with the letter for your alignment lit, BROKEN once spirits are hostile, blank at zero; the flash paints after it and wins an overlap), the command bar (rows 22–24, yellow, columns 1–23, parted from the rest by blank columns; F2 hides it viaCommandBar) and the message log (rows 23–24, from columnLOG_X, or column 0 once the bar is hidden). The log’s--MORE--prompt waits for the effect layer — seelog_panel, under “The two blocking loops” below. - Inventory overlay — drawn last, on top of everything. The F1 key list (
draw_help, rows fromstrings::help_rows), the Info popup (draw_item_info, itsItemInfolines wrapped at 54 columns) and the quit prompt sit in the same layer.
occupied_by_actor, computed once up front, is the set every “don’t draw under a mob” rule in steps 3–8 checks against.
Blindness takes the colour out of the map. While the player carries Blind, every map-space colour in steps 2, 4, 5, 6, 7 and 8 goes through by_touch, which returns Color::White for all of them — the 3x3 the visibility system left them reads as bare shapes felt out by hand. Remembered tiles keep their DarkGrey, and the detection layer keeps its magenta: neither is something the player is looking at.
The HUD
Two lines, on opposite sides of the map: the dungeon’s half on row 0, the player’s half on row 22, directly under the viewport. The second one costs the map’s bottom row; that is why the log is two lines. The command bar (strings::onboarding_keys, three keys one per row) takes the left of the bottom three rows, so the log and the player line both start at LOG_X and the log wraps at LOG_WIDTH (54), not the map’s 80. F2 hides the bar and both slide to column 0; the log then wraps at LOG_FULL_WIDTH (80). CommandBar::log_x and log_width give the pair, and hud::log_view and hud::acknowledge take the width, so the page drawn and the page cut always agree. A message wider than that wraps at a word boundary onto the next line; when it runs past the second line the --MORE-- pager keeps the unshown words (hud::acknowledge), so no word is lost between pages.
Row 0 carries condition badges from column 1, DEPTH n centred (the word in magenta, the number white) and the score pinned to the right edge in white, with PACK: (used/total) in white just left of it (the backpack’s length over PACK_CAPACITY, the same cap a pickup checks). Nothing on it is laid out relative to anything else on it, so a player wearing six badges cannot push the depth or the score off the line.
Row 22 is the player: name, HP x/y, Ma x/y, Pow., Arm., optionally Skl. — joined with " · " in DarkGrey. The label carries the colour and the figure beside it stays white — Ma dark cyan, Pow. red, Arm. cyan, Skl. green, the name white — so the line reads as one row of numbers over a colour-coded key rather than six differently coloured numbers.
HP is the one field that colours its own number, because the number is the thing that changes meaning: yellow normally, DarkRed once models::player_too_injured is true. That is the same call auto-fight refuses under, not a second threshold copied into the HUD, so the field can never say “fine” about a bar Tab will not swing on.
The score itself is view::score_text, shared by the HUD and both end panels: six digits zero-padded (SCORE 000140), and past what six digits hold, an order of magnitude instead — 4.09M, 1.31B, 9.44T, and 4.61e18 past a thousand trillion, where the suffixes run out. Truncated, never rounded: a score should never read higher than it is. A score that has saturated reads MAXIMUM: it is not a number any more, it is a ceiling.
The scorekeeper flashes, in the gutter under itself. While models::ScoreFlash is lit — one frame per payment; see components.md, “Components — score” — the flash paints on row 1, right-aligned under the score, a character at a time from the flash’s own colour list: +700 in one random bright colour, COMBO! +2400 with the word in the six flag stripes and the number in one colour, or DOUBLE. The running total keeps its place on row 0 throughout, so the frame that pays shows both what was paid and what it came to. Row 1 is the map’s own top row, which is wall or nothing, and the flash is painted after the map layers (step 14, with the player line) so terrain cannot paint back over it.
The displayed Pow./Arm./Skl. figures are not just Fighter.power etc. — they fold in every equipped modifier via one models::loadout call, the same fold combat_system runs, so the HUD can never drift from the number combat actually rolls against. One pass, not one per field: this runs on every frame of every animation. Skl. only appears once it’s nonzero, since a player who never picked up something that boosts throws never needs to see a field that would always read +0. The bonus on Pow./Arm. carries its own sign and is omitted entirely when it is zero, so a plain weapon reads Pow. 10, an enchanted one Pow. 10+2 and a cursed one Pow. 10-2 — the sign comes from the number, never from a + glued in front of it.
Condition badges, in the order checked: FAST/SLOW (read through conditions::tempo, not off Speed.kind — so a ring of slow digestion reads SLOW exactly like a wand of slow monster does), STLH (Stealthy, a ring of stealth), CONF (Confused), BLND (Blind), PARL (Paralyzed), GLOW (ConfusingTouch, a scroll of monster confusion still waiting on the next blow to land), PLAT/FORG (the two coin promises — the only badges that are good news; see components.md, “Components — player conditions”), WARD/BIDE, then a snare label, worst first (STONE for a medusa’s gaze, ASLEEP for sleeping gas, HELD for a bear trap or a scroll of hold monster), then an auto-walk badge (EXPLORING/TRAVELING, or ASCENDING — magenta — once the player carries the Element of Yoord), then TRAVEL? while the O cursor is open. Each is independent; several can show at once — though never more than three ledger conditions, which is a rule about the creature rather than about the line: see components.md, “A creature carries at most three conditions”. A worn ring’s STLH, the tempo badges and the two coin promises are priority badges, outside that count and never shed, for the reasons on the same page.
A long enough badge run paints over the centred DEPTH, and that is accepted. The three anchors on row 0 do not negotiate with each other — that is what splitting the HUD bought — so the only way the line could stay clean under three conditions, a tempo, STLH, both promises and an auto-walk badge would be to drop a badge the player needs. Nothing panics either way: puts no-ops past the frame edge. A player wearing that much at once did it to themselves.
Animation playback
%%{init: {'theme':'base','themeVariables':{
'primaryColor':'#20242b','primaryTextColor':'#d7dae0',
'primaryBorderColor':'#5c6370','lineColor':'#8a8f98',
'fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace',
'fontSize':'13px'}}}%%
flowchart LR
subgraph blocking["blocking — the turn is already resolved"]
direction LR
P1["advance one frame"]:::magic --> P2["render"]:::cold
P2 --> P3{"key?"}
P3 -- no --> P4{"anything<br/>left?"}
P4 -- yes --> P1
P4 -- no --> P5(["done"])
P3 -- "yes: swallow it" --> P6(["skip to the end"]):::hero
end
subgraph shake["play_shake — never blocks"]
direction LR
S1["advance one frame"]:::magic --> S2["render"]:::cold
S2 --> S3{"key?"}
S3 -- no --> S4{"still<br/>rocking?"}
S4 -- yes --> S1
S4 -- no --> S5(["settled"])
S3 -- "yes: leave it unread" --> S6(["settle, repaint,<br/>hand the key back"]):::hero
end
blocking ~~~ shake
classDef hero fill:#3a3418,stroke:#d7ba4a,color:#e8dfa8
classDef peril fill:#3a1f1f,stroke:#c05050,color:#f0c8c8
classDef magic fill:#2f2038,stroke:#a86fc0,color:#e6cdf0
classDef cold fill:#17323a,stroke:#4aa3c0,color:#bfe4f0
Both of these are blocking loops called once per main-loop iteration, after the schedule runs and before the frame’s own render. Both are no-ops when nothing armed them, so a turn that fought nothing and read no scroll passes straight through. (The third playback loop, play_shake, is the one that does not block — it has its own section below.)
pub fn play_particles<W: Write>(world, stdout, screen) -> std::io::Result<()>
pub fn play_magic_map<W: Write>(world, stdout, screen) -> std::io::Result<()>
Both share the same shape: drain_input first, then — while the effect (Particles/ MagicMapReveal) still has something to show — advance it one frame, call render to paint the result, and interrupted for the frame’s duration — a key press during that poll is swallowed and skips straight to the finished state (magic mapping additionally finish_magic_map_reveals the map instantly rather than leaving it part-revealed). Frame duration is AnimRate-scaled (-anim-rate, clamped 0.1..=5.0) off a 33ms base for particles and MagicMapReveal::frame_ms() for the map wipe, so a slow terminal or a player who wants snappier turns can retune both without either one’s code changing.
The two helpers around that poll exist for one bug: the loops treat a pending event as “skip this”, and the key that started the animation can still be echoing — a terminal that reports key releases hands back the release of the zap key, which killed the bolt on its first frame and made a wand look like it drew nothing at all. drain_input empties the queue before the first frame; interrupted counts only Event::Key, and term::read hands up nothing but presses and clicks, so a release, a resize or a mouse report arriving mid-flight is not a skip. Keys typed ahead are still swallowed, one frame earlier than before.
The turn itself is already fully resolved by the time either of these runs — they only animate what already happened, which is what makes it safe to block input here the way NetHack and DCSS do for a bolt.
The --MORE-- prompt waits for the effect layer (log_panel, which packs layer 14’s lines and answers for the prompt in the same breath). Because that poll takes any key as “skip”, a prompt asking for Space while motes are still on screen asks for the one key that throws the rest of the batch away — and a batch is ordered. A trick shot’s blast is queued behind the missile’s flight (Particles::hold_ms), so the key lands during the wind-up, eats the explosion, and leaves the flight looking fine: the shot animates perfectly right up to the part worth watching. A trick shot raises the prompt every time — it shouts, kills, drops the dead one’s gear and then says “Very clever.”, eleven messages for two lines of log — which is why that was the blast nobody could get to play. Only the prompt waits: the lines that fit are painted throughout, and the backlog is out of reach for no longer than the animation the player is already watching.
Both also call age_shake once per frame and settle_shake on the skip-keypress, so a shake armed by the same turn keeps decaying over whichever animation happens to be on screen — which is the common case, not an edge one: a blast arms the shake and queues its particles in the same breath, and the two are meant to be seen together. Skipping the sparks skips the shake with them; they are one effect.
The screen shake
pub fn play_shake<W: Write>(world, stdout, screen) -> std::io::Result<()>
Same shape as the two loops above — advance, render, poll — with one deliberate difference that is the whole reason it is a separate function: it never blocks on the player.
play_particles can afford to freeze for 200 ms because it animates an aftermath the player asked for by swinging. A shake is armed by the dungeon, at exactly the moments a player is most likely to be typing ahead: mid-fight, or half a second from dying. A flourish that eats a keystroke there is a flourish that gets a flag turned off. So play_shake runs only in the gap where nothing is waiting to be read, and the moment poll reports a key it settles the map, repaints one steady frame, and returns without consuming the key — leaving it for the input handler that was about to block on it anyway. Worst case, a player typing through a shake sees one frame of it and no more.
It runs as Step C1 of the main loop, straight after the frame that armed it and before the loop can block again, which is what guarantees the map is never left frozen mid-lurch on screen: either the shake runs out inside play_shake or a keypress settles it there.
What is armed, and by what, is the table in components.md, “The screen shake”. render reads the current displacement into Screen::map_shift at the top of every frame, so nothing else in this file has to know the feature exists. -nshake turns it off at the source (Shake::enabled), so nothing is ever armed; -anim-rate scales its frames like every other animation’s.
A player death is the one moment with nothing left to protect, and it arms no shake at all — the map holds still and helpers::death_burst plays out three times slower instead, in Step B2, before the main loop reaches its Ending check and run_death_screens paints over it.
One thing to know if you are measuring: a shake frame changes most of the map’s ~1700 cells, so it is the one situation where the cell-diff has little left to skip. It lasts 2-15 frames.
End-of-run panels
pub fn render_you_died(term, screen) -> std::io::Result<()>
pub fn render_lose(term, screen, player_name, cause, score)
pub fn render_win(term, screen, player_name, score, marks)
Full-screen, drawn in place of render (not layered with it) via screen.clear() and screen.dirty_all = true — a full repaint, since nothing about the map frame should bleed through. Both end panels are one word — WIN or LOSE — then the name, the cause (death only), the score and the prompt; centered_x centres every line. main.rs’s run_death_screens/run_victory_screens drive these: death shows the “You die…” panel, blocks for the acknowledging keypress (wait_for_key), then shows LOSE and blocks again; victory shows WIN and blocks once. The win panel also draws marks, one coloured ◆ per conduct kept, under the score (conducts.md). Both write the run dump (morgue.rs) first, while the last map frame is still the one on the terminal: Screen::shown_text reads that frame, so the dump’s final screen is what the player last saw.
The inventory overlay
fn draw_inventory(world: &mut World, screen: &mut Screen)
A bordered box sized to the widest row actually being drawn (row_text, the one place a row’s text — letter, name, " (E)" for equipped — is assembled, so sizing and drawing can never disagree about a row’s width) or 30 columns, whichever is larger. Row colour is a small decision table on (selected, cursed_known, equipped) — known_quality gates the cursed colouring, so an unidentified cursed item still reads as ordinary. The Use/Drop/Throw/Info action modal, when open, is a second small box floated to the right of the item row it belongs to, its four labels coming from ItemAction::MENU.
draw_quit_prompt is the overlay’s sibling and the last thing painted in the frame, so “Really quit?” sits over everything: a box centred on the map (two lines, four once QuitPrompt.warned adds the red time-stopped rows), spelling out both answers rather than leaning on “any key”. It is the only modal in the game whose job is to slow the player down, which is also why it is centred instead of tucked into a corner where a key could be answered by reflex.
Which rows the inventory box has, and the heading over them, come from PackIsOpen::mode: models::pack_rows for the rows and PackMode::title for the heading, so the drawing code holds no filter of its own and can never show a row the cursor cannot reach. The rows it gets back are backpack indices — the row’s letter is that index, while its screen line is its position in the filtered list, which is why the two are tracked separately in the loop. See components.md, “The pack screen”.
See also
input-and-turn-loop.md the other half of the frame: keyboard and turns
../explanation/the-feel-layer.md why each effect looks the way it does
../reference/components.md the resources named throughout this page
../reference/cli-and-env.md -c, -anim-rate, -nb, -nshake
../explanation/the-feel-layer.md why each effect looks the way it does
Reference: the spawn API
Audience Engine developer, or anyone writing a test that
needs a specific thing on a specific tile.
Prerequisites Rust, and a passing familiarity with bevy_ecs.
Status Signatures as they are in the source. If this page
and the source disagree, the source is right and this
page is a bug.
Everything here is re-exported from the crate root, so use models::*; is enough.
The one you probably want
pub fn spawn_named(
world: &mut World,
name: &str,
pos: Position,
) -> Option<Entity>
models/src/spawn.rs
Spawns whatever the game knows by that name – monster, any item category, trap, or the relic – and returns None if it knows nothing by it. What comes out is the thing exactly as its row describes it: no enchantment roll, no ammunition bundle.
let dragon = spawn_named(&mut world, "dragon", pos).unwrap();
let sword = spawn_named(&mut world, "long sword", pos).unwrap();
assert!(spawn_named(&mut world, "sandwich", pos).is_none());
Lookup is a case-sensitive exact match on the row’s name, and the tables are searched in this order: bestiary, then each DROPS category in table order, then traps, then the relic. Names are unique across all of them – every_content_name_is_unique enforces it – so the order does not affect the result.
Roughly a hundred rows, all const, all in cache: a linear scan beats building a HashMap at startup and is one less thing to keep in sync.
Listing what exists
pub fn content_names() -> Vec<(&'static str, &'static str)>
Every name spawn_named answers to, as (category, name), in table order. Categories are "monster", the ten DROPS names, "trap" and "relic".
Backs the -content flag. Reads the tables, so it can never go stale.
Rolling loot
pub fn roll_item(
world: &mut World,
rng: &mut ChaCha12Rng,
depth: u8,
pos: Position,
) -> Entity
One floor drop: a weighted draw for the category, then a weighted draw within it, then ItemDef::spawn_as_loot – so the result is enchanted, is charged, does arrive as a bundle.
This is the only place the dungeon decides what loot exists.
pub fn pick_weighted(weights: &[u32], rng: &mut ChaCha12Rng)
-> Option<usize>
Picks an index in proportion to the weights. None for an empty slice or all-zero weights. One RNG draw whatever the table’s length.
pub const DROPS: &[DropCategory]
impl DropCategory {
pub fn rows(&self, depth: u8) -> Vec<&'static str>
}
The loot table, and the rows one category can produce at a depth.
Monsters
models/src/monsters.rs
pub fn spawn_monster(
world: &mut World,
def: &MonsterDef,
pos: Position,
) -> Entity
The one place a monster is brought into the world. Builds the full bundle, attaches the row’s granted effects, and adds Invisible if the row asked. Every spawn site goes through it.
impl MonsterDef {
pub fn named(name: &str) -> &'static MonsterDef // panics
pub fn lookup(name: &str) -> Option<&'static MonsterDef>
pub fn pick(depth: u8, rng: &mut ChaCha12Rng) -> &'static MonsterDef
pub fn pick_any(rng: &mut ChaCha12Rng) -> &'static MonsterDef
}
named is for string literals you wrote yourself – it panics on a miss, which is what you want in a test. lookup is for a name from outside the source, such as a save file. pick is the weighted, depth-gated draw floor generation uses. pick_any is the same weighted draw with the depth gate removed – floor population switches to it once the player carries the Element of Yoord (map::levels::holding_element_of_yoord), so the climb out can throw the whole bestiary at any floor.
Traps
models/src/traps.rs
impl TrapDef {
pub fn of(effect: TrapEffect) -> &'static TrapDef // panics
pub fn lookup(name: &str) -> Option<&'static TrapDef>
pub fn pick(depth: u8, rng: &mut ChaCha12Rng) -> &'static TrapDef
}
impl TrapBundle {
pub fn from_def(def: &TrapDef, reveal: TrapReveal, position: Position)
-> Self
pub fn random(rng: &mut ChaCha12Rng, depth: u8, position: Position)
-> Self
// plus one named constructor per trap, for tests:
pub fn trapdoor(position: Position) -> Self
pub fn bear(position: Position) -> Self
pub fn sleep(position: Position) -> Self
pub fn teleport(position: Position) -> Self
pub fn arrow(position: Position) -> Self
pub fn dart(position: Position) -> Self
}
The named constructors all use TrapReveal::Sight, so a test can see the trap it placed. random rolls both the kind (weighted, depth-gated) and the reveal style (uniform).
TrapBundle is a bundle, not an entity: world.spawn(bundle).
Items, by kind
models/src/catalog.rs
Convenience wrappers over the tables. Each panics on an unknown name or effect – they exist for call sites holding a literal.
pub fn spawn_potion(world, effect: PotionEffect, pos) -> Entity
pub fn spawn_scroll(world, effect: ScrollEffect, pos) -> Entity
pub fn spawn_wand(world, effect: WandEffect, pos) -> Entity
pub fn spawn_ring(world, effect: RingEffect, pos) -> Entity
pub fn spawn_weapon(world, name: &str, pos) -> Entity
pub fn spawn_armor(world, name: &str, pos) -> Entity
pub fn spawn_ammo(world, name: &str, pos) -> Entity
pub fn spawn_launcher(world, name: &str, pos) -> Entity
pub fn spawn_element_of_yoord(world, pos) -> Entity
spawn_named covers all of these. Prefer it in new code unless you have an effect enum rather than a name.
The ItemDef trait
pub trait ItemDef {
fn name(&self) -> &'static str;
fn spawn(&self, world: &mut World, pos: Position) -> Entity;
fn weight(&self) -> u32 { 10 }
fn min_depth(&self) -> u8 { 1 }
fn spawn_as_loot(&self, world: &mut World, rng: &mut ChaCha12Rng,
pos: Position) -> Entity { self.spawn(world, pos) }
}
Implemented by all nine item row types so the loot roller can treat every category alike. See ../how-to/add-an-item-category.md.
Other catalog helpers
pub fn enchant_equipment(world, rng: &mut ChaCha12Rng, item: Entity)
Rolls quality for a freshly spawned piece of gear and stamps the result on. Reads the item to decide which bonus applies.
pub fn split_one(world: &mut World, item: Entity) -> Option<Entity>
A fresh single unit of whatever item is a stack of, spawned nowhere in particular – the one arrow that leaves a quiver when you shoot it. None if the item is not something the catalog can make more of.
pub fn restore_from_catalog(entity: &mut EntityWorldMut, id: &str)
Re-attaches what a row gives an item that the save file does not store: how a weapon behaves in flight, what a bow lends its wielder, what a missile answers to. Keyed by the row’s id, because the row is the definition. The save stores the id (content), found at save time from the entity’s display name.
Effects
models/src/effects.rs
impl Grant {
pub const fn of<C: Component + Default>() -> Self
pub fn attach(&self, entity: &mut EntityWorldMut)
pub fn detach(&self, entity: &mut EntityWorldMut)
pub fn probe(&self, world: &World, entity: Entity) -> bool
}
A const handle to one marker effect. Lets a const table name a component it cannot store.
lend and revoke_matching are the two ends of the ledger (Effects, a Vec<Held> on the entity): what is attached, from where, and for how long. One id may appear more than once – a creature born fire-immune and wearing a ring of fire resistance holds two entries – so revoke_matching is the single place that asks “is anything else still lending this?” before detaching. attach_effects returns the ids a save named that this build has no row for.
pub fn grant_all(world, entity: Entity, grants: &'static [Grant])
pub fn revoke_all(world, entity: Entity)
pub fn lend(world, entity: Entity, grant: Grant, lifetime: Lifetime) -> bool
pub fn revoke_matching(world, entity: Entity, doomed: impl Fn(&Held) -> bool)
pub fn hold(world, victim: Entity, grant: Grant, turns: u32) -> bool
pub fn turns_left(world: &World, entity: Entity, grant: Grant) -> Option<u32>
pub fn effects_of(world: &World, entity: Entity) -> Vec<Held>
pub fn attach_effects(entity: &mut EntityWorldMut, held: &[Held]) -> Vec<&'static str>
pub fn equipped_total<C: Modifier>(world: &World, entity: Entity) -> i32
pub fn loadout(world: &World, entity: Entity) -> Loadout
equipped_total is where gear turns into one number: it sums a modifier across everything an entity has equipped plus anything it carries itself. The throw code folds ThrowBonus through it; the trap damage rule folds ArmorBonus. Neither knows what kind of item supplied the value.
loadout is the same fold with the loop on the outside, and it is what callers that want all of them use – combat, for both sides of a blow, and the HUD, for the five figures on the status line. It returns a Loadout (power_die, power_bonus, armor_die, armor_bonus, throw_bonus, and the strictest melee_cap, which folds with min rather than +). There is no cache and no second copy of anything: Equipped is still the only record of who wears what, and this reads it once instead of six times.
The struct is not written out by hand. Every field but the cap is generated from the modifiers! rows in effects.rs, along with the line of Loadout::absorb that sums it, so a modifier cannot be declared and then quietly left out of the fold – see ../how-to/add-an-effect.md. The cap is written out separately because it folds with min.
The dev shortcut
pub fn spawn_requested(
world: &mut World,
near: Position,
occupied: &mut HashSet<(u16, u16)>,
)
Reads NIHILURK_SPAWN and drops each named thing on a free tile around near. Called at the end of floor generation.
pub fn spawn_list(
world: &mut World,
list: &str,
near: Position,
occupied: &mut HashSet<(u16, u16)>,
) -> usize
The same thing without the environment lookup, for tests. Returns how many names the tables recognised.
Floors and seeds
models/src/map/
pub fn layout_rng(seed: u64, depth: u8) -> ChaCha12Rng
pub fn content_rng(seed: u64, depth: u8, changes: u32) -> ChaCha12Rng
pub fn difficulty_tier(depth: u8) -> u32
A floor’s two private streams: layout_rng builds its walls, content_rng fills it. layout_rng is a pure function of (seed, depth), so the walls of a floor never move. content_rng also takes changes – the run’s FloorChanges count, bumped by every staircase, portal and trapdoor – so the contents are re-rolled each time the floor is entered. The two streams are independent, so a change to one cannot move the other.
difficulty_tier(depth) is the 0-4 crowding band the monster and trap budgets read (DIFFICULTY_TIER_LAST_DEPTH).
Floor generation draws from these and never from the shared GameRng. That is what keeps a seed’s maps fixed regardless of how the player fought their way through – and keeps the contents keyed to a clean staircase count rather than to the blow-by-blow. GameRng is for the live run – combat, item effects, traps springing.
pub fn create_map(world: &mut World) -> ((u16, u16), Vec<Rect>)
Builds the current floor (reading Depth and RngSeed) into the Map resource and returns the player’s start tile. Leaves GameRng untouched.
pub fn regenerate_map(world: &mut World, seed: u64, depth: u8)
Rebuilds one floor’s tiles without spawning anything. This is why the save file does not store a map: (seed, depth) is enough to get the exact layout back (the save carries the staircase count so the contents come back too).
Held by models/tests/it/determinism.rs.
Constants
pub const ELEMENT_OF_YOORD: &str = "The Element of Yoord";
See also
content-tables.md the tables these functions read cli-and-env.md NIHILURK_SPAWN and the -content flag ../how-to/spawn-a-thing.md recipes for the functions above ../how-to/work-with-the-ecs.md spawning, despawning, and the borrows ../how-to/add-an-item-category.md implementing ItemDef yourself
ADR 0001: content lives in Rust tables, not in raw data files
Status Accepted
Audience Engine developer evaluating or revisiting this
decision. Assumes Rust, ECS and serialisation
background. Not for content authors -- you want
`../how-to/add-a-monster.md`.
Supersedes --
Related data-driven-content.md
Context
nihilurk’s content had grown into a shape that was already half data: one const table per item kind, a bestiary table, a row per thing. Three things were still hand-written code, and each was a place where adding content meant editing something unrelated to the content:
- The loot table was a
matchover hand-computed percentage ranges inmap.rs(nowmap/population.rs). Adding a category meant recomputing every boundary. - Monster depth gating was a
tierfield decoded by a formula ((depth - 1) / 2, capped at 3) that lived in the spawner, not on the row. - Traps had no table at all – six enum variants, a
label()match, aconst ALLarray, and a uniform pick.
There was also no single entry point for “give me one of X by name”. Tests and any future scripting had to know which of nine spawn_* helpers to call.
The reference for fixing this is chapter 45 of Roguelike Tutorial - In Rust (bfnightly.bracketproductions.com/chapter_45.html), which argues for moving entity definitions out of source entirely and into external JSON “raw” files, loaded at startup into a RawMaster registry indexed by name, with weighted spawn tables carrying min_depth.
The question was how much of that to adopt.
Decision
Adopt the chapter’s structural ideas. Reject its storage format.
Adopted:
-
One spawn entry point keyed by name.
spawn_named(world, name, pos)resolves any name the game knows – monster, item, trap, relic – and returnsNoneon a miss. This isspawn_named_entityfrom the chapter. -
Spawn tables as data, decoupled from definitions.
DROPSis a weighted, depth-gated list of categories.MonsterDefandTrapDefcarryweightandmin_depthon the row.pick_weightedis the one draw function all three share. -
A registry that reads the tables.
content_names()enumerates everything, which backs the-contentlisting and the table tests.
Rejected:
- External JSON/RON raw files parsed at run time. Content stays in
consttables in Rust source.
Rationale for rejecting external raws
Portability. nihilurk is meant to run on anything with a terminal, and ships as one static binary with nothing beside it. Raw files introduce a data-directory lookup – relative to the binary? the working directory? an install prefix? – and a class of failure (“could not find raws/spawns.json”) that a single binary simply does not have. Embedding the files with include_str! avoids that but throws away the runtime editability that was the whole point.
Compile-time checking is worth more here than late binding. A typo in a Color, a missing field, a Grant naming a component that does not exist – all of these are compile errors today. In JSON they become startup panics at best and silent defaults at worst. The tables are edited by the same people who edit the engine; giving up the type system to serve a designer who does not exist yet is a bad trade.
Grant cannot survive the trip. The effect system’s central trick is that a const table names a component – Grant::of::<FireImmune>() is a const handle holding function pointers that attach, detach and probe one type. JSON can only carry the string "FireImmune", so a raw-file port would need a name-to-Grant lookup table maintained by hand: a second list of every effect, which is exactly the kind of thing this architecture exists to avoid.
The save format is coupled to the tables by name. Items are saved as a name and rebuilt via restore_from_catalog; monsters recover their innate grants by bestiary lookup. That coupling is good – it keeps saves small – but it means a raw file and a save file can disagree. Compiled tables cannot drift from the binary that reads them.
The iteration win is smaller than it looks. The argument for raws is “edit and rerun without a rebuild”. A content-only rebuild here is about 1.2 seconds. Against that, NIHILURK_SPAWN removes the far larger cost, which was never compilation – it was playing down to floor 9 to see a floor-9 monster.
Three dependencies and a parse step, for nothing measurable. A raw port would add a format crate, a loader, an error path, and a startup cost, to serve a workflow already served.
Consequences
Accepted costs:
-
Adding content requires a Rust toolchain and a rebuild. Someone who wants to add a monster must be able to run
cargo build. This rules out non-programmer modders and end-user modding entirely. -
No runtime content. No downloadable content packs, no user-supplied bestiaries, no reloading a table without restarting.
-
Content changes are code changes, so they go through code review and version control. This is also a benefit, and it is why the tables read like data files:
#[rustfmt::skip]and column alignment are there so a row can be diffed and reviewed as a row.
Benefits realised:
-
Adding a weapon, armour, coin, ammunition or launcher is one line and no other edit. Adding a monster or a trap is one line plus, for a trap, a mechanic the compiler demands.
-
The loot table has no arithmetic in it. Category weights are relative, so a new category cannot invalidate an existing one.
-
content_names()andspawn_namedgave-content,NIHILURK_SPAWNand a table-driven test suite for one function each. -
Zero new dependencies. The binary is still self-contained.
When to revisit
Reopen this decision if any of these becomes true:
-
Modding becomes a goal. If players are meant to add content, they cannot be asked to install Rust. That alone overturns this.
-
A designer without Rust joins. The trade above assumes the people editing tables are the people editing the engine.
-
The tables outgrow review. At a few hundred rows they still read well. At a few thousand – procedural generation of content, say – a data format with its own tooling starts to pay for itself.
-
Content needs to change without a release. Live tuning, remote balance patches, A/B testing a drop rate.
The migration path is deliberately short. Every table is already a flat list of plain-data rows behind a pick/lookup/spawn interface, and every consumer goes through those. Swapping the backing store for a parsed file means reimplementing MonsterDef::lookup, TrapDef::lookup and one_named – and the one genuinely hard part, Grant, which would need a name-to-handle map. Nothing else in the codebase would notice.
See also
data-driven-content.md what the architecture buys, in prose ../reference/spawn-api.md the interface this ADR is about bracketproductions.com/chapter_45.html the raws design this rejects
ADR 0002: every crate has a package name and a library name
Status Accepted
Audience Anyone who opens `models/Cargo.toml` and wonders why
the package is `nihilurk-models` and the library is
`models`, or who is about to rename one of them.
Supersedes --
Related ../how-to/publish-to-crates-io.md
Every crate in this workspace has two names, and they are different on purpose.
directory models/
package name nihilurk-models what crates.io and `cargo -p` see
library name models what `use models::*;` sees
Context
The directory and the library name are the short ones the code was written with. Publishing to crates.io forced the package name to change.
crates.io gives out names on a first-come basis. engine, models and strings were all taken by other people before nihilurk existed, so a crate published under those names would be refused. particle-core was free, but it was marked publish = false, and the crates above it cannot go out unless it does.
That last point forces the issue. engine depends on models and strings by path. crates.io does not accept a path dependency: it needs a version that resolves in its own index. So publishing the game means publishing the whole chain, and each link in it needs a name nobody else holds.
Decision
Rename the packages. Keep the directories and the library names.
- Every crate that ships is
nihilurkornihilurk-<thing>.engine/tests/workspace.rsfails if a new one is not. - The binary’s package takes the bare name
nihilurk, which is free. The binary was already callednihilurk, socargo install nihilurkinstalls the thing it says. - A package name and a library name are separate settings in Cargo, so two lines keep the library names:
`[lib] name = "models"` in the crate itself, so its own
integration tests keep compiling.
`models = { package = "nihilurk-models", ... }`
in the crate that depends on it, so
the dependency is still called
`models` in the code.
compat/is alreadynihilurk-compatand ispublish = false. It is a test rig that builds the game for other machines, and it depends on ratatui, which the shipped binary may not. Nothing installs it, so it has no name to defend.
Rejected:
- Renaming the library too. The library name defaults to the package name with hyphens turned into underscores, so renaming the package alone would have made it
nihilurk_modelsand broken everyuse models::line in the game, the tests and the docs. Renaming the library means touching every file that mentions it, a diff a hundred times the size for a gain the player never sees. - One crate. Fold
models,stringsandparticle-coreinto onenihilurkcrate as modules and publish once. Two reasons against:particle-coreisno_stdwith no dependencies so anyone can build it for a machine with no OS and get the arithmetic the game runs, and folded in it would sit besidebevy_ecsand the save-file code, and that would end. Andmodelsis the crate the documentation is written around: almost every page undertutorial/,how-to/andreference/names a file in it, so moving it is a rewrite of the docs, not a release task.
Consequences
Accepted costs:
- A reader has to know both names.
cargo -ptakes the package name, so it iscargo test -p nihilurk-modelsandcargo run -p nihilurk, while the code saysmodels. This ADR is the page that explains it.
Benefits realised:
- The game, its tests and its docs kept every
use models::line. cargo install nihilurkinstalls the game.particle-corestays small,no_stdand dependency-free.
When to revisit
Reopen this decision if any of these becomes true:
- crates.io stops being a target. With nothing published, the package names can go back to the short ones.
- A short name is released to us. If
modelsorengineever becomes available, the rename is a different trade. - The docs stop being written around
models. Then folding crates costs less than it does now.
See also
../how-to/publish-to-crates-io.md the release recipe
cross-platform-testing.md why particle-core stays separate
../../engine/tests/workspace.rs the tests that hold the names in place
ADR 0003: the compatibility matrix checks that nihilurk starts, not that it is fast
Status Accepted
Audience Anyone changing `compat/`, tempted to add a load test
to it, or wondering why the matrix grades no machine
on speed.
Supersedes --
Related cross-platform-testing.md
Every machine in the matrix is asked two things: does its image have a shell, and does nihilurk -content run on it. Nothing asks how fast.
Context
gdd.md claims nihilurk runs on anything with a terminal. The matrix exists to make that claim fail loudly.
The first version of the matrix checked it under load. It drove the game inside a resource-capped container: a real dungeon floor for the gate, Bad Apple at eight hundred motes a frame for a ceiling, and a pty-attached run to prove the terminal stack actually draws. The game-load and reel runs sat behind an opt-in --exec-emulated path, because they made ioctl calls a qemu-user-static translation layer could get wrong. The rig was borrowed wholesale from a stress rig built to find performance cliffs on a workstation.
It worked. Bad Apple ran through the particle system with no trouble on the maintainer’s workstation. The rig was added in 6fdb716 and removed in e18db33, and git show 8efa8ec:perf/bad-apple still has it. The video file is why a clone weighs what it does: 10.7 MB raw, about 685 KiB packed, and the largest object in a 3.4 MiB repository.
Decision
Check that the target builds, that its image has a shell, and that nihilurk -content runs. Do not check under load.
cross_build.shcross-compiles a full Rust toolchain for the target. That is a heavier job, by time, memory and code paths exercised, than nihilurk’s own frame loop will ever ask of the machine. A machine that can do it can run nihilurk.- The two things the build cannot prove are the shell and the start.
run_check.shchecks exactly those, withnihilurk -content: a headless print-and-exit that never touches the terminal. - No CPU cap, no memory cap, no pty sizing, no
--exec-emulated.
Rejected:
- A stress rig for performance cliffs. nihilurk has no workload that asks a machine to be fast. Every feel-layer effect is bounded: a dozen sparks when something dies, not a music video. A Pi that cannot keep up with a synthetic ceiling might still play nihilurk at a comfortable thirty frames a second, so grading it on that ceiling answers a question nobody asked. It also costs CPU caps, memory caps, qemu interpreter plumbing and pty sizing to keep working.
Consequences
Accepted costs:
- No performance claim. A machine can pass the matrix and still play badly. The size of the shipped binary is on the report per target, and that is the only number it gives.
- No proof that the terminal stack draws on a target. The check avoids the terminal on purpose.
Benefits realised:
- The check needs nothing from the environment beyond a shell, so it runs unattended under qemu-user-static.
- The video file and its rig are out of the tree.
- One verdict per row (
runs,no shell,does not run,-), not a performance band, so there is one answer to defend.
When to revisit
Reopen this decision if any of these becomes true:
- A feel-layer effect becomes unbounded. Something that scales with input, not with a fixed budget.
- A real workload appears. Large floors, many creatures, anything that asks a machine to be fast.
- A player reports a slow machine the matrix passed.
See also
cross-platform-testing.md the matrix as it is built ../how-to/run-the-compat-pipeline.md running it the-feel-layer.md why every effect is bounded
ADR 0004: presentation is checked by playing, not by cargo test
Status Accepted
Audience Anyone about to add a test of how something looks,
or restore one.
Supersedes --
Related the-feel-layer.md
Nothing in the feel layer, the renderer or the HUD has a test of how it looks. Game rules keep their tests. The rule is: assert game rules, not their presentation.
Context
The feel layer once had a lot of coverage: which shake a blow earned, how a shake decayed and what outranked what, the corpse-fling and bone scatter, mote birth and culling, the map-cell clipping a shake pushes tiles through, the log line’s colours and the pride stripes, and an end-to-end test that rendered a frame and asserted glyph rows. It came to just over seventeen hundred lines.
All of it shared two problems.
It asserted the wrong thing. A shake is punctuation: what matters is whether it reads as heavier than the one below it, and no assertion can tell you that. A test can say ShakeKind::Heavy lasts 260 ms, a number you can also read off ShakeKind::shape in less time than the test takes to compile.
It broke for reasons that were never bugs. Presentation tests are coupled to presentation, so moving a layer, renaming a badge or reordering the HUD fields turned them red without anything being wrong. The end-to-end render test was the worst: its fixture hand-mirrored twenty-two of main’s startup resources, with a comment admitting as much, so it drifted every time startup changed and said nothing when it did.
Decision
Delete the presentation tests, all in one pass, and keep the rule.
A test earns its place if it pins something a player could be cheated by: damage arithmetic, what a seed generates, what survives a save, what a menu offers. If the worst case of it being wrong is “that looked a bit off”, it belongs in a playtest.
Three things keep tests, because they are not about feel:
- The
--MORE--gate.log_panelholds the prompt back until the effect layer is empty, becauseplay_particlesreads any keypress as “skip”. A trick shot’s blast is queued behind the missile’s flight, so the key would eat the explosion and leave the flight looking fine. The player is cheated of the shot they lined up, and the frame it happens in looks correct, which is the one case playing does not catch. Two tests inview.rspin it, on a fixture of two resources. particle-core. It isno_std, andf32::ceilhas two implementations. If they disagree, ano_stdbuild animates differently from the game.tests/parity.rsruns both undercargo test.models/tests/it/determinism.rs. It proves cosmetic randomness comes offFxRngand never perturbsGameRng.
Consequences
Accepted costs:
- The draw order in
renderis guarded by nobody. A later layer silently covers an earlier one, and only a look at the terminal, in more than one terminal if you can, catches it. - A regression in a shake or a colour ships unless someone sees it.
Benefits realised:
- Moving a layer, renaming a badge or reordering HUD fields turns nothing red.
- No fixture mirrors
main’s startup.
When to revisit
Reopen this decision if any of these becomes true:
- Presentation carries a rule. If a colour or a glyph becomes the only way a player can tell something that changes their choice, it is a game rule and gets a test.
- Playtesting stops catching things. If layers regress and nobody sees it for releases, the cost above has come due.
See also
the-feel-layer.md the layer this covers
../reference/rendering.md the frame, and the order it is drawn in
cross-platform-testing.md why particle-core stays tested
ADR 0005: a zap and a throw resolve before the mobs move
Status Accepted
Audience Anyone reordering the turn schedule, or wondering why
`item_system` and `throw_system` sit ahead of `ai`.
Supersedes --
Related ../reference/input-and-turn-loop.md
item_system and throw_system run before ai. throw_system is not ordered after trap_system.
Context
A zapped wand is aimed at the dungeon as it stood when the key was pressed. For a while the schedule resolved it after ai, so it read a tile the target had already walked off. That made the aiming reticle look broken.
The bolt wands hid it: a bolt sweeps a line and a blast a disc, so they still caught somebody. But every wand that reads one exact tile (teleport away, teleport to, polymorph, haste, slow, cancellation) reported finding nothing there while the reticle had been on the monster the whole time.
throw_system showed the same fault from the other side. Aimed at the empty tile in front of an approaching orc, the dagger arrived after the orc had stepped onto it and hit one it was never thrown at.
throw_system was also ordered after trap_system. Nothing needed that.
Decision
Order both before ai, and drop the edge after trap_system.
- A shot that comes down on a trap sets the trap off through
detonate_at, inside the throw’s own resolution, so it never waits for the trap step. - The two orderings are pinned by
a_zap_lands_on_the_tile_the_player_aimed_at_not_the_one_the_target_leftanda_throw_lands_where_the_floor_was_when_the_player_let_goinupdate.rs. - The schedule is built by
turn_schedule()inmodels/src/schedule.rsrather than inline inmain, so those tests can run it.
Consequences
Accepted costs:
- A turn order with load-bearing edges. Moving either system later breaks aiming, and only the two tests say so.
Benefits realised:
- What the reticle sat on is what the zap or the throw hits.
When to revisit
Reopen this decision if any of these becomes true:
- Aiming becomes real-time. If the target can move between the key and the resolution on purpose, the ordering is a design question.
- Another system needs the post-
aiworld. Then it needs its own step, not a move of these two.
See also
../reference/input-and-turn-loop.md the schedule, step by step
../how-to/work-with-the-ecs.md adding a step with an .after() edge
ADR 0006: effects are saved by stable string id
Status Accepted
Audience Anyone adding, retiring or renaming a row in `EFFECTS`.
Supersedes --
Related ../how-to/add-an-effect.md
The save file stores each effect as a stable string id, not a position. Enum variants elsewhere in the save are still stored by position, and are append-only.
Context
Effects were once saved as positions in a u64 bitset. That capped the list at 64, and it made retiring a row dangerous: removing one shifted every later effect in every save, and nothing could tell, because every index was still a valid index.
Decision
Pair each effect with a string id in EFFECTS, and save the id.
- Rows may be reordered and retired freely. The one rule is: never rename an id. A bestiary row lives by the same rule.
- An id a save names and the build has no row for is dropped, counted, and reported to the player in one line at the end of the load. Losing one property beats losing the run.
- There is no ceiling on how many effects there can be.
Consequences
Accepted costs:
- A rename is a save break. An effect renamed in code but not in its id is fine; an id renamed breaks every save that holds it.
- A missing row half-works. An effect missing from
EFFECTSattaches but is not saved, so it vanishes on reload.
Benefits realised:
- Retiring a row changes nothing about the others.
- A save from a build with an effect the current build lacks still loads.
When to revisit
Reopen this decision if any of these becomes true:
- The save is versioned. A migration step could carry renames, and the never-rename rule could relax.
- Ids become a maintenance burden. If keeping them stable costs more than losing a property, the policy of dropping unknown ids is the thing to revisit.
See also
../how-to/add-an-effect.md adding a row, and the warning about ids
../reference/content-tables.md the EFFECTS table
ADR 0007: aggravation is a component laid over the tactic
Status Accepted
Audience Anyone touching `Aggravated`, or tempted to model a
monster's state as a `MovementType`.
Supersedes --
Related agents.md
A shriek makes every monster on the floor beeline for the noise. That state is the Aggravated component. It is not a MovementType.
Context
Aggravation was once a MovementType variant carrying the tile of the noise. As a tactic it overwrote the tactic it was laid on, so an aggravated venus flytrap forgot it was a flytrap.
Decision
Make aggravation a component on top of the tactic, so the tactic is still there to go back to.
- Out of the player’s view, a monster carrying
Aggravatedtakes one step toward the noise, never leashed, and waits once there. In view, it hands back to its rule set. An aggravated flytrap walks in from the next room, then lies in wait like a flytrap once you can see it. MovementType::Aggravated { tx, ty }stays in the enum, retired, because a save writes the enum by position. A save that carries it loads asChaseplus the component. It is never set.
Consequences
Accepted costs:
- A dead variant. Nothing can remove it without breaking old saves.
Benefits realised:
- A tactic survives being aggravated and being un-aggravated.
- Another state that should not replace a tactic has a precedent.
When to revisit
Reopen this decision if any of these becomes true:
- The save is versioned. The retired variant could then be migrated away.
- A second state needs the same treatment. Then the pattern wants a name of its own.
See also
agents.md the rule sets and what each tactic does
../reference/agents.md the Aggravated rule, in the tables
../reference/components.md the component and the retired variant
ADR 0008: the player’s step is a queued intent
Status Accepted
Audience Anyone adding a verb the player can do, or moving a
rule in or out of `engine/src/update.rs`.
Supersedes --
Related adr-0005-aim-resolves-before-the-mobs-move.md
The player’s step, blow, lunge and struggle, and taking the stairs, dropping, a willed teleport, a charge and a reach attack, are decided when the key is handled and applied by the first step of the schedule. The use, throw and spell queues already worked this way.
Context
A step used to happen in the input handler, before the schedule ran. Every other intent was queued and drained by a schedule step, so the player’s own verbs were the one place a rule lived outside the turn. A marker such as EntityMoved was set by the input layer and cleared by trap_system, and nothing but a comment tied the two.
Decision
Plan at the key, apply in the schedule.
models::plan_stepreads the world and returns aStepPlan. It changes nothing except the confusion roll, which has to happen at the key so the random draws stay in their old order.models::queue_stepplans, queues aPlayerAction::Stepunless the plan was a refusal, and returns whether the turn is spent. A bump into a wall is a refusal that spends nothing, so the monsters do not move. The other verbs follow the same shape:queue_stairs,queue_drop,queue_willed_teleport,queue_chargeandqueue_reach_attackcheck what would refuse, log it and return false, or queue the action.player_action_systemis the schedule’s first step. It drainsPlayerActionQueueand applies each plan. Everything else in the turn runs after it, so the player still acts beforeai.
The plan is made against the world as it stands when the key is handled. Nothing runs between that moment and the schedule, so the plan is still true when it is applied.
Consequences
Accepted costs:
- A fifth queue and a seventeenth step. Every page that states either number changes.
- A step is no longer visible the instant the handler returns. Code that reads the world right after
queue_stepsees the old position. Callers run the schedule first, and tests callplayer_action_system. - Two places describe one decision tree.
plan_stepandapply_stepboth name the same variants.
Benefits realised:
- A bot can drive the real turn by queueing actions, which
models/tests/it/soak.rsdoes. EntityMovedis set and cleared insidemodels.- The rules of a step live in
models, where they are tested without a terminal.
When to revisit
Reopen this decision if any of these becomes true:
- A verb cannot be planned without running it. Then the plan would need to run the rule to know whether it spends the turn.
- Something must run between the key and the schedule. The plan would no longer be true when applied.
See also
adr-0005-aim-resolves-before-the-mobs-move.md the same ordering, for aimed use and throw
../reference/input-and-turn-loop.md the step, in order, and the schedule
../reference/components.md PlayerActionQueue
Why monsters think in rule sets
Audience Anyone deciding how a creature should behave, or
wondering why `ai.rs` makes no decisions of its own.
Prerequisites `../reference/agents.md` for the names.
This is Reasoning, not instructions.
Every mob in nihilurk is a reflex agent. Each turn it is handed what is true right now, it picks one thing to do from an ordered list of rules, and it forgets all of it. There is no memory, no plan, no state machine to be in the middle of. This page is why.
No memory
A monster that remembers has somewhere for a bug to live. “It saw you two rooms ago and is still coming” needs a place to keep the sighting, a rule for when to drop it, and a save field for it, and every one of those is a way for a monster to act on something the player can no longer see or check. A turn built from a fresh percept can only ever act on the floor as it is.
It also makes the rules testable without a world. A rule is a plain function of the Percept, so a test builds one by hand, runs think, and reads the answer. Nothing has to be spawned, walked into place or aged. The randomness a rule needs (which spell to try, which way to stagger) is rolled into the percept for the same reason: the rule stays a function of what it is handed.
The one thing that looks like memory is Aggravated, and it is not the monster’s. It is a fact laid on the monster from outside, by a scroll or a ring, the way Asleep is.
The player’s view is the gate
A mob on a tile the player cannot see does nothing. Sight in nihilurk is symmetrical (the player sees the room, the room sees the player), so a monster out of view has, as far as the game is concerned, nothing to perceive. The floor stays quiet out of sight, and nothing happens off-screen that the player could not have watched.
Two things break that on purpose. An aggravated monster heard a shriek, and a shriek carries through walls, so it walks toward the noise. The player’s Helper walks back to the player, because a companion stranded around a corner is no companion. Both are single moves with no percept behind them, and both hand back to their rule set the moment they are in view: an aggravated venus flytrap walks in from the next room, then lies in wait like a flytrap once you can see it.
That is also why aggravation is a component. As a MovementType it would overwrite the tactic it was laid on, and an aggravated flytrap would forget it was a flytrap. As a state on top of the tactic, the tactic is still there to go back to.
First match wins
A rule set is an ordered list, and the first rule that fires takes the turn. Order is the whole design: CAST before SHOOT before STRIKE before CLOSE_IN says “magic if you can, a bow if you can, otherwise go to melee”, and that sentence is the set. There are no weights to tune and no scores to compare, so reading a set tells you exactly what the creature does, and moving one line changes it.
The chaser’s loop is deliberately dumb about its spells: it picks one at random and fires it if it can, and if it cannot it does not try another. A dragon with one spell breathes every time it has a line. A creature with three spells is unpredictable in the way a player can plan around, rather than always casting its best one.
Allies look before they cast
A wild monster does not care whom its fireball catches, its kin included. An ally does: every spell an ally casts is checked against the player’s tile first, and a foe standing too close to the player is passed over for one further off, or for a bite. That check lives in the rule (CAST asks ally), not in the spell, because the spell is the same mechanic whoever casts it. What differs is whose side the caster is on.
The check knows each spell’s footprint. A spell it has no footprint for is assumed to reach the player, so an ally never casts one blind. It does not follow chain reactions: a fireball that sets off a trap or a coin can still burst onto the player.
What ai.rs keeps
The clock (energy, rounds, the two passes a fast monster gets), the gates (dead, asleep, stone) and the hands (carrying an Action into the world). It never decides anything. A new behaviour is a new rule or a new set in agents.rs, and ai.rs does not change.
See also
../reference/agents.md every rule and set data-driven-content.md the same idea for content tables adr-0007-aggravation-is-a-component.md why aggravation sits on top of the tactic
Code calisthenics in nihilurk
Audience Anyone changing engine or model code and wondering
why a function they were about to write an `if/else`
in has none anywhere near it.
Prerequisites You write Rust. You have a diff open.
This is Understanding and a house rule, not a tutorial. It
tells you what shape the code is kept in and why, so
your change lands in the same shape.
Code calisthenics is a set of deliberately strict constraints you apply while editing, to push code toward a shape it does not fall into on its own. The constraints are not laws of good code – some of them are arguable – but holding to one across a whole crate makes that crate uniform, and uniform code is code you can skim.
nihilurk adopts them one at a time, crate by crate, each pass touching nothing but the shape. A pass never changes behaviour: the test suite is the proof, and it stays green from the first edit to the last.
Phase 1: no else
Done in models/ and engine/. A control-flow else – } else { or } else if – does not appear in either crate.
An else is almost always one of a small number of things wearing a disguise, and each has a plainer form:
A guard that should have returned early
fn tile(&self, x: u16, y: u16) -> TileType {
if x >= MAP_WIDTH || y >= MAP_HEIGHT {
TileType::Wall
} else {
self.tiles[tile_index(x, y)]
}
}
becomes
fn tile(&self, x: u16, y: u16) -> TileType {
if x >= MAP_WIDTH || y >= MAP_HEIGHT {
return TileType::Wall;
}
self.tiles[tile_index(x, y)]
}
The out-of-bounds case is handled and dismissed; the rest of the function runs at one indent level, unconditionally, because by the time you reach it the bad input is gone. This is the single most common transformation, and in a loop the early exit is continue rather than return.
A branch on an enum or an Option that should have been a match
if arg == "-s" {
...
} else if arg == "-c" {
centered_mode = true;
} else if arg == "-ns" {
no_save = true;
} else {
positional = Some(arg.clone());
}
becomes
match arg.as_str() {
"-s" => { ... }
"-c" => centered_mode = true,
"-ns" => no_save = true,
_ => positional = Some(arg.clone()),
}
A chain of else if comparing the same value against constants is a match that has not admitted it yet. When the arms test two bools that are really one decision, match the pair:
let color = match (i == selected_idx, *equipped) {
(true, _) => Color::Yellow,
(_, true) => Color::Cyan,
_ => Color::White,
};
An if let ... else whose else is the None case is match ... { Some(x) => ..., None => ... }.
Two branches that do most of the same thing
if last && more {
screen.puts(0, y, line, Color::White);
screen.puts(57, y, "--MORE--", Color::Yellow);
} else {
screen.puts(0, y, line, Color::White);
}
becomes
screen.puts(0, y, line, Color::White);
if last && more {
screen.puts(57, y, "--MORE--", Color::Yellow);
}
Pull the shared statement out; keep only the difference behind the if. The same move collapses if a { X } else if b { X } into if a || b { X }.
A tail of cases too big for the calling function
When the branches are long and the function is already deep – the speed-shift message, the auto-fight turn, the two attack-report blocks in resolve_attack – lift them into a named function whose body is guard clauses:
fn speed_shift_message(name: &str, faster: bool, is_player: bool, changed: bool) -> String {
let extreme = if faster { "quick" } else { "sluggish" };
if !changed && is_player {
return format!("You are already as {extreme} as you can be.");
}
if !changed {
return format!("The {name} is already as {extreme} as it can be.");
}
match (is_player, faster) { ... }
}
The call site loses a branch and gains a name that says what the branch was for.
A returning if followed by its own else
if let Some(idx) = action_mode {
...
return Ok(turn_taken);
}
else { ... }
If the if block ends in return (or continue, or break), the else is noise. Drop the keyword and dedent the block. The return already says the rest is the fall-through, and a comment would only say it again.
The one else that stays: the expression ternary
Rust has no ?:. The nearest thing is let x = if c { a } else { b }, and for a two-way choice of a value that is the clearest form there is:
let sx = if x0 < x1 { 1 } else { -1 };
let dice = if excellent { EXCELLENT_HIT_DICE } else { 1 };
let label = if equipped { " (E)" } else { "" };
These are kept. Rewriting them as match c { true => a, false => b } trades a familiar idiom for an unfamiliar one and reads worse. A two-way if/else in expression position, both arms a bare value, no statements is not the else this rule is about.
The moment a branch grows a statement, or a third case appears, it is back in scope: a three-way value choice is a match, and a branch that does something rather than evaluating to something is a guard.
Checking a pass
grep -rn '} else' models/src engine/src --include='*.rs'
Every hit should be a let ... else { ... } (the guard-clause let, which is encouraged) or an expression ternary as described above. Anything else is a regression.
cargo test --workspace is the proof that the pass changed only shape. Run it before the first edit and after the last; it does not move.
Phase 2: shallow nesting
Done in models/ and engine/. No function nests more than about three blocks deep – a function body, a loop, and one conditional inside it, give or take. The match inside an if inside a for inside a while inside a for is gone.
The limit is ~3, not 1, on purpose
The strict calisthenics rule is one level of indentation per method. That is the right pressure but the wrong stopping point for nihilurk. Pushed all the way it turns one readable 40-line function into eight three-line ones, each named, each taking six world-threading parameters, and you now read a call graph instead of a procedure. The bloat costs more than the nesting did.
So the rule here is a ceiling, not a target: get out of the deep nests, stop when the function reads top to bottom without scrolling your eye rightward. In practice that lands around three levels.
How the depth comes down
Mostly the same moves as Phase 1, plus two:
Extract the loop body. A for whose body is 60 lines and three levels deep is a function that has not been written yet. ai::monster_round is a for over step_one_mob; items::item_system a for over resolve_use; update::process_input_and_update is split into handle_targeting_input, handle_inventory_input, handle_movement_input and their callees.
Name the phases of a procedure. Not every long function is deep; some are just long, and read as one undifferentiated wall. The cure is the same and the motive is different – you are not reducing indentation, you are giving a reader somewhere to stop. combat::resolve_attack was 180 straight lines and is now seven named steps (fold_matchup, roll_swing, clamp_swing, land_swing, punctuate, report_blow, settle_the_dead), each of which can be read or skipped on its name. map.rs had the same problem one level up – 1,374 lines doing four unrelated jobs – and became map/ with a submodule per job, the way items.rs did before it.
The test for whether an extraction earned its place: can you name what came out in three words, without “helper” or “handle” in them? If not, the split is cosmetic and the parameter list it costs is not worth it.
Reach for iterators before nesting. .filter().map().flat_map() and .any() / .find_map() flatten a loop-plus-conditional into one expression with no indentation at all. visibility::flood_fill_room walks neighbours(x, y) – an iterator that yields the in-bounds neighbours – instead of a for dy { for dx { if in_bounds ....
Iterator chains are not subject to a “one method call per line” rule. Chaining is how idiomatic Rust expresses a data-structure transform, and breaking a chain across statements to satisfy a dot budget makes it harder to read, not easier. A long chain is one thought; leave it whole.
Checking a pass
# statements indented 24+ spaces (6+ levels) -- should print nothing
grep -rnP '^ {24,}\S' models/src engine/src --include='*.rs' \
| grep -v '^\S*:[0-9]*: *//'
A handful of 4-5 level spots remain by choice – the renderer’s cell-diff double loop, a couple of for / match / if combinations where the extraction would be pure ceremony. The test suite is again the proof the pass changed only shape.
Rules nihilurk deliberately does not adopt
The rest of the classic Object Calisthenics list, and why each is a poor fit here rather than an oversight:
Wrap every primitive in a type. nihilurk passes (u16, u16) tile coordinates and (i16, i16) steps around by the hundred. A Point newtype would carry the arithmetic, but Rust tuples already destructure, Copy, and pattern-match cleanly, and the operations are mostly one-liners (.signum(), saturating_add_signed) that read fine inline. The wrapper would be ceremony without a payoff. If a coordinate type ever grows real behaviour – distance metrics, neighbour iteration used everywhere – revisit it then.
First-class collections (wrap every Vec/HashMap in a domain type). This one would actively hurt. The collections in nihilurk are short-lived locals – a HashSet of visible tiles, a Vec of mob entities for this pass, a spatial HashMap rebuilt every tick. Wrapping each in a named type with its own methods would couple call sites to an interface that exists for one function’s benefit, and make the loose, rearrange-it-in-five-minutes character of this code stiff. The ECS already supplies the real domain structure; the collections are just scratch space, and scratch space benefits from staying informal.
One dot per line. Covered above: chaining is load-bearing in Rust and nihilurk uses it deliberately. Not adopted.
Keep entities (structs) small. This one is sound – a component with eight fields is usually two components – and worth keeping in mind when you add one. It is not enforced here only because nihilurk’s components already tend to be small (Position is two fields, most grants are zero), so there is nothing to clean up. Treat it as advice, not a pass.
One rule of nihilurk’s own: a test never asserts a constant
Not a calisthenics rule, but it belongs next to them, because it is the same kind of discipline and it has already cost us a morning.
Never write a test whose assertion is a copy of a tuning number. A test that says a wand rolls 3..=9, that a floor hides a stash one time in five, or that a kill is worth 700 points is not testing the game; it is testing constants.rs, which needs no help. The moment somebody rebalances – and the working tree is usually mid-rebalance – that test goes red while the code is perfectly correct, and it teaches the next person to distrust the suite.
Three ways out, in order of preference:
- Assert the relation. “A thrown wand hits harder than the zap it gave up.” “Two corpses in one turn beat two corpses in two turns.” “A coin’s burst reaches a tile a trap’s does not.” These stay true across every rebalance because they are what the feature is.
- Read the constant.
DAMAGE_DICE * DAMAGE_SIDESinstead of8;share_of("coin")offDROPSinstead of17.0. Now the test proves the roller honours the table rather than proving the table says what it says. - Purge it. If a test’s only claim was the number, delete it and put something real in its place. “A stashed item carries
InvisibleandHidden” is the kind of claim to put there: an invariant a rebalance cannot touch and a bug could.
A test’s own loop bound is not a tuning number and should not borrow one. models/tests/it/autoexplore.rs asserts termination against a local NEVER: u32 = 20_000, not against travel::AUTO_EXPLORE_STEP_CAP – the cap is a safety valve somebody tunes, and borrowing it would make a tightened valve look like an infinite loop.
One rule of nihilurk’s own: behavior tests build components, not bestiary rows
A behavior test should construct the smallest entity that carries the property under test. Do not spawn an orc to test equipment, a dragon to test fire immunity, or a phantom to test invisibility. A bestiary row is a content definition, and spawning it also brings along its grants, gear rolls, speed, name and other balance decisions. That makes an unrelated row edit change the fixture before the assertion runs.
Use a neutral monster fixture for ordinary monster mechanics, and attach the
specific component or grant the test needs. For example, a test of undead
drain immunity should add Grant::of::<Undead>(); a test of catching gear
should add ItemUser; a test of invisibility should insert Invisible.
The assertion then names the contract directly and cannot pass because a
species happened to carry extra behavior.
Keep bestiary-backed tests only where the bestiary itself is the subject:
content-table validation, playable-body definitions, spawn behavior, and
roster-wide contracts. Those tests should say so in their names and comments.
The shared neutral fixtures live under models/tests/it/common/monster.rs.
One rule of nihilurk’s own: no comments inside a function body
In engine, models and particle-core, a comment sits above the thing it describes. It never sits inside a function body, test functions included.
A comment in a body is a name or a function that has not been written yet. “Skip the dead” becomes if is_dead(mob) { return }. “Then the second pass” becomes a function called second_pass. The code stays where it is and the sentence moves into an identifier, where a rename keeps it true and an edit to the code cannot leave it behind.
These stay, because they sit outside a body:
- Doc comments (
///) and file headers (//!). - A note over a signature, or between its parameters.
- A note on its own line directly above a closure, which describes the closure the way a signature note describes a function. A blank line between the note and the closure breaks it, and so does a note on the closure’s own line or inside its body.
- The notes over a catalog table (
BESTIARYand its kin) that say how to add a row. They are about the table, not about a function.
The pre-commit hook enforces this (.githooks/no_body_comments.lua), and .githooks/no_body_comments_test.lua is its test. It reads the staged file as cargo fmt writes it. CI does not run it, so a clone without the hook can break the rule and review is what catches that.
To check every tracked file at once, run lua .githooks/no_body_comments.lua --tree. Every hit is a file, a line and the comment. It reads what git has staged, so git add your edits first.
See also
data-driven-content.md the other thing that keeps nihilurk small ecs-in-nihilurk.md what the shape is holding up ../how-to/work-with-the-ecs.md the borrow patterns this shape assumes ../README.md “documentation ships with the change”
Why monsters are so fragile
Audience Anyone choosing numbers for a new monster or a new
piece of gear and wondering what "good" looks like.
Prerequisites None. Helps to have read a bestiary row.
This is Understanding, not instructions. For the numbers
themselves see `../reference/content-tables.md`; for
the act of adding a row, `../how-to/add-a-monster.md`.
A kestral has three hit points. That is not a placeholder.
One exchange, two dice
nihilurk resolves a fight as two independent rolls, subtracted:
damage = (1d[power] + power_bonus) - (1d[armor] + armor_bonus)
Both sides roll every time. There is no to-hit roll and nothing ever misses – a swing that fails is not a miss, it is a hit the armour ate.
Each 1d there is really two dice of that size averaged, rounding down (BELL_CURVE_DICE). The range is the plain die’s; the spread is narrower, and the rounding shades the mean down by about a quarter point. An excellent hit, below, rolls its dice flat.
That is the melee formula. Damage comes in three kinds, and armour answers each differently:
Melee armour die + armour plus (the formula above)
Missile armour plus only (every throw, every shot)
Magic nothing (wands, spells, blasts)
Missiles go around the armour die because a point already in flight does not care what you are wearing, but an enchantment still turns it. Magic goes around everything. There are no exceptions: an arrow or dart trap fires a missile, so it takes the plus; every other damage source – a spell, a wand, a blast, a trick shot’s burst – is magic and takes nothing.
Everything a creature wears or holds folds into those four numbers before they are rolled, and combat never learns what supplied them. A long sword, a suit of plate mail and a ring of protection all arrive as the same kind of modifier.
Micro-HP: the pool is not the defence
The instinct when making something dangerous is to give it more hit points. In nihilurk that makes it slower, not scarier, and the difference matters.
A creature’s survival comes from winning the armour roll, not from absorbing many losses. A troll with armor: 6, armor_bonus: 1 shrugs off most of what a dagger can produce; it does not need thirty hit points to feel dangerous, and if it had them the fight would just take longer while being no more frightening. The whole bestiary lives between 3 and 13 hit points on purpose.
The practical rule: raise armor and armor_bonus to make a thing hard to kill; raise power and power_bonus to make it frightening to stand next to; raise hp only to buy a creature one more exchange.
The tension this buys is Rogue’s: every fight is short, every fight can go wrong, and once your armour falls behind, an orc with a lucky roll can end a run that had a plan. Fragility cuts both ways – the player has twelve hit points.
The two rules that apply only to the player
Both exist to stop the maths above producing a stalemate, which is the one outcome a turn-based fight cannot survive.
-
Excellent hit. A share of the player’s swings (
EXCELLENT_HIT_CHANCE) rollEXCELLENT_HIT_DICEdice of[power]instead of one, before armour is subtracted. Without it, a player in poor gear facing good armour has no path at all. It also shatters the suit a foe has on, and a cursed suit throws splinters (CURSED_SPLINTER_DIE) into every other foe withinCURSED_SPLINTER_RADIUS. -
Chip damage. The player’s worst swing still takes
CHIP_DAMAGEoff – but a blow that weak can never be the killing one. It leaves things alive on the last point of HP. Without it, an unlucky player against a well-armoured monster can swing forever and never move the number.
The two dials above and the chip floor (CHIP_DAMAGE) are in constants::combat. See ../reference/constants.md.
Monsters get neither. A monster that cannot hurt you simply cannot hurt you, and that asymmetry is what makes armour worth wearing.
The price of a hand
One slot holds one thing, and nihilurk has no wait action – you cannot spend a turn swapping and then act. So the hand you commit is committed until you spend a real turn getting out of it, with whatever is next to you getting a free swing.
That is the whole design of the bow. Drawn, it is the best thing in the dungeon: it looses an arrow on the row’s own launched_die, a bigger die than the IMPROVISED_THROW_DIE a hand-thrown one rolls (a crossbow’s quarrel rolls bigger still, a blowgun’s dart trades its die for venom), its enchantment rides along on every shot, and a corridor is a killing lane. Swung, it carries MeleeCap(1) and is worth a bruise – less than your bare fists, which is the point. Three hundred swings with a +5 bow deal 300 damage; three hundred with a long sword deal about 2,400; bare-handed, about 1,000.
So an archer is not a melee character with a ranged option. An archer is someone who has decided that nothing will reach them, and has to be right. Letting the bow also be a decent club would collapse that decision into a free upgrade, and the cheapest way to keep a decision honest is to price the thing you did not choose.
Why most gear is cursed
Most weapon, armour and ring drops roll cursed: whatever NORMAL_QUALITY_PCT and EXCEPTIONAL_QUALITY_PCT leave over. That sounds punishing until you look at the bonus range: a cursed item rolls anywhere in CURSED_BONUS_MIN..=CURSED_BONUS_MAX, which reaches above zero, so it is often better than the plain ones. What you are gambling is not the number, it is the commitment – a cursed item cannot be taken off again without a scroll of remove curse, which will destroy it, or the matching scroll of enchantment, which lifts the curse and mends the minus but has to be found and has to match the slot.
A numeric ring is the exception to “better than the plain ones”. Its curse rolls from -3 to +2 and the plain ring is +2, so a cursed ring is never the better ring, only the one that will not come off. An exceptional one is a flat +3.
So the drop table is not “most of your loot is bad”. It is “most of your loot is a decision”. Picking up an unidentified sword and putting it on is the game asking whether you are sure.
The bonus always lands on the flat modifier and never on the die size, so a +3 dagger is still a dagger. Weapon class is a property of the weapon; enchantment is a property of the copy you found.
Traps that scale
A trap on floor 2 and the same trap on floor 11 are the same row in the same table, but they should not bite the same. The two damage traps – arrow and dart – grow with depth in bands that end at the floors in constants::traps::TRAP_DAMAGE_TIER_LAST_DEPTH. Each band adds ARROW_DAMAGE_PER_TIER to the arrow trap’s damage roll and DART_POWER_DRAIN_PER_TIER to the dart trap’s permanent strength drain, so the deepest dart trap that connects costs several points of power for the rest of the run.
Those bands are deliberately coarser than the floor-crowding ones (DIFFICULTY_TIER_LAST_DEPTH, which steps more often so the deepest floor gets its own worst budget). A trap that stepped that often would out-scale the player; fewer bands are enough to make depth felt.
The bear trap pins your feet and nothing else: you can still swing at whatever walked up to you while you were stuck, and the turns are spent shrinking, not frozen. Trying to walk out tears the leg – a point of damage, a wasted turn, and a floor tile you will recognise later. The dial is constants::traps::BEAR_TRAP_THRASH_DAMAGE; the gore is cosmetic and separate.
Reading the existing table
Rough shape of the bestiary, if you want a new creature to sit in it without standing out:
fodder hp 3-6 power 4-6 armor 4-6 depth 1
mid hp 3-10 power 4-10 armor 4-12 depth 5
deep hp 13 power 8-12 armor 6-10 depth 10, with bonuses
Each row also keeps its original Rogue level and armour class in a comment table at the top of models/src/monsters.rs, as a design anchor. nihilurk does not model either, but they say what the creature was for.
See also
../reference/content-tables.md the fields, the enchantment odds ../reference/constants.md every balance knob and where it lives ../how-to/add-a-monster.md putting a row in data-driven-content.md why a creature is a row at all ../../gdd.md what the game is trying to be
Why the compatibility matrix is built this way
Audience Anyone changing `compat/`, adding a machine to the
matrix, or wondering why the matrix is built this way.
Prerequisites You have run `./compat_test.sh` once.
This is Understanding. The recipe is
`../how-to/run-the-compat-pipeline.md`.
gdd.md makes a claim: nihilurk runs on anything with a terminal. That is a nice thing to say and an easy thing to be wrong about. Nobody notices a 32-bit build breaking, because nobody builds 32-bit, and nobody notices the binary gaining three megabytes, because the machine it was built on has plenty.
The compatibility matrix exists to turn that claim into something that fails loudly.
What is being checked
If it builds, the image it runs in has a shell, and the target is std (every linux row here is), nihilurk can run there.
That claim is not checked under load. nihilurk has no workload that asks a machine to be fast. Every feel-layer effect is bounded – a dozen sparks when something dies, not a music video – and a Pi that cannot keep up with a synthetic ceiling might still play nihilurk at a comfortable thirty frames a second. Grading a Pi on a ceiling it will never face answers a question nobody asked, and costs a stress rig with its own CPU caps, memory caps, qemu interpreter plumbing and pty sizing to keep working.
What the matrix actually needs to prove is narrower: cross_build.sh already cross-compiles a full Rust toolchain for the target, which is by every measure that matters (time, memory, code paths exercised) a heavier task than nihilurk’s own frame loop will ever ask of that machine. A machine that can do that can run nihilurk. The two things left to check are the ones the build itself cannot prove – does the image have a shell to launch the binary from, and does the binary actually start there – and run_check.sh checks exactly those two things, with nihilurk -content: a headless print-and-exit that needs nothing from the environment beyond a shell to launch it from.
One table, two readers
compat/matrix.tsv is the pipeline. One row per machine, and adding a machine is adding a line – nothing else holds the list.
Two things read it: cross_build.sh builds a row, and run_check.sh checks it. compat/src/matrix.rs compiles the same file in with include_str! so the report can label what it is showing. Same decision as the content tables, for the same reasons (adr-0001-tables-not-raws.md): a malformed row is caught when the pipeline is built rather than in the middle of a run.
This is also why there is no docker-compose.yml. A compose file would be a second copy of the machine list, written in a second syntax, and the day the two disagree is the day the matrix quietly stops testing what it says it tests. The runner is a shell script over the table instead.
There are no Dockerfiles either, for a reason that is the whole point of the musl decision below: there is nothing to install. A row’s container is an unmodified upstream image with one static binary mounted into it read-only. Building a custom image per architecture would add a layer to maintain, a thing to rebuild, and a place for a runtime dependency to creep in unnoticed – and if nihilurk ever did need something installed alongside it, the pipeline would be quietly hiding the fact that the claim in gdd.md had stopped being true.
The images are pinned rather than floating on :latest, so the same command on two machines a month apart is the same measurement.
Why every Linux row is musl
A glibc binary is bound to the glibc it was linked against. Build on a current Arch and the result refuses to start on a Debian oldstable or an older Pi OS, with a GLIBC_2.38 not found that has nothing to do with nihilurk and everything to do with the machine it was built on.
A -musl target links the whole libc in. What comes out is one file with no INTERP segment and no runtime dependency of any kind – copy it to the machine and run it. That is the only kind of build that honours the claim in gdd.md, so matrix.rs has a test that fails if a -gnu row is ever added.
It costs a little size, because a static binary carries its own libc, and that cost is on the report per target rather than hidden.
Why every linux row must have a shell
A row’s class of linux is a claim: this machine has an operating system, a terminal and a shell, and nihilurk can therefore run on it. That is not a decorative distinction from bare – it is the one thing that determines whether run_check.sh will even try to execute a row, and it is checked, not assumed. check_has_shell in compat/lib.sh runs /bin/sh -c 'echo shell-ok' in the row’s image before the binary runs, and a row that fails it fails the pipeline – an image with no shell in it cannot host a terminal program no matter how the binary was built, and bare already exists for “compiled, never executed”; a linux row that cannot prove a shell is effectively a bare row with the wrong label.
This is also why the ARM lineup is Raspberry Pi boards – pi-zero, pi2, pi3, pi4, cm – rather than a longer list of cloud SKUs. graviton earns its place because an M-series Mac’s Linux VM is a common, real way nihilurk gets built and played, but a cloud instance is otherwise an abstraction over hardware someone else runs. A Pi is hardware: it is a board with a shell on it that someone might actually plug a keyboard into, which is closer to what “does nihilurk run on that machine” is asking than a t4g-family instance size is. pi2 and pi3/pi4/cm share pi-zero and graviton’s existing triples (armv7-unknown-linux-musleabihf and aarch64-unknown-linux-musl respectively) rather than needing new Cross.toml entries – the point of adding them was a wider, more realistic spread of real ARM boards, not a new architecture.
Why nihilurk -content and not a real session
The check has to run under qemu-user-static for most of the matrix (see below), and it has to run unattended, so it needs to be something that starts, does something observable, and exits on its own. A real game session does none of that: it waits on a keyboard.
nihilurk -content prints the game’s content index and exits – see the comment above list_content in engine/src/main.rs, which is explicit that this never touches the terminal’s alternate screen. That is what makes it safe under emulation: there is no pty to size and no terminal ioctl (TIOCGWINSZ, raw-mode toggles) for a translation layer to get subtly wrong, only a shell to launch the binary from and stdout to read back. If it prints something and exits 0, the row can run nihilurk – the terminal-drawing half of that claim is exactly what cross-compiling and starting the binary already stand behind, and testing crossterm’s raw-mode behaviour under an architecture qemu-user does not fully emulate would be answering a question about qemu, not about nihilurk.
Why each target gets its own target directory
compat/lib.sh puts every triple’s build in target/cross/<triple>/ rather than the workspace’s own target/.
A cross build compiles two different things. The crate is compiled for the target, and cargo already namespaces that by triple. Every dependency’s build script is compiled for the host, and those all land in one shared target/release/build/ with nothing in the path to say which toolchain produced them.
So they get reused where they must not be:
cargo build --release # host: Arch, glibc 2.42
./compat_test.sh # i686 image: Ubuntu 16.04, glibc 2.23
The second command finds the first one’s build scripts already built, runs them inside the older container, and they die on weak version GLIBC_2.29 not found. The error names thiserror’s build script, which nihilurk does not depend on and which has nothing to do with the change being tested. Two cross images of different vintages collide the same way, so it is not enough to keep the host out of it.
One directory per triple means no two toolchains ever share a host artifact. It costs disk and a full recompile the first time each target is built, and it buys a pipeline whose answer does not depend on what happened to be built before it – which for a pipeline whose entire job is to be trusted about other machines is the only acceptable trade.
Running under emulation
x86_64 runs natively. So does i686: a 64-bit kernel runs 32-bit user space directly, so the potato row is starved rather than emulated.
aarch64 and armv7 run under a qemu-user-static interpreter run_check.sh fetches and runs directly – no binfmt_misc, no --privileged. It can do that because every binary the matrix runs is static: the interpreter never needs a foreign sysroot, only to translate the guest’s syscalls, so it works as the container’s own command on a container built for the host’s architecture.
qemu-user-static’s job is exactly that – syscall translation – and nothing more, which is what makes it safe to lean on for nihilurk -content: the check makes no ioctl calls a translation layer could get wrong, because it never touches the terminal.
The cloud row is the control: the host’s own architecture, no emulation in the way.
Why there is a headless check
headless_check.sh says what the game can do without a terminal: models, which holds every rule, has no crossterm in its dependency graph, and its tests pass without one. The terminal lives in engine/src/term.rs and nowhere else, and engine/tests/term_boundary.rs keeps it there. So a new front end (a web page, a bot) takes models whole and writes its own term.rs.
It is host-only and has no matrix row, since having no terminal is the same on every machine. It is not in the nihilurk-compat report either; it fails on its own, and compat_test.sh lists it as skipped when it does. --no-headless leaves it out, and ./compat/headless_check.sh --quick runs just the graph and the boundary, in a few seconds.
Why the grading lives in Rust
The scripts write raw status strings and never judge them beyond that. nihilurk-compat turns them into a verdict, in results.rs, with tests.
The alternative is a threshold in awk, and then a second threshold in the Rust report, and then a CI check with a third. Three answers to one question is worse than no answer, because everyone believes whichever one they saw first. The verdict is runs, no shell, does not run or -, not a performance band, so there is little to disagree about, and the reason for keeping it in one place, checked by tests, still stands.
Who is to blame for the binary
cross_build.sh prints the size of the shipped binary per target, and then attributes it per crate.
cargo bloat is the better tool for that and cannot be used here: pointing it at a foreign target means having that target’s linker on the host, which is exactly what building in a container exists to avoid. So the attribution is read off symbol sizes with llvm-nm, which reads every architecture rustc can emit.
It reports symbol bytes, not file bytes – the two differ by section headers, alignment padding, relocations and the constant pool, so the table’s total comes in under the size printed beside it. Read the shares, not the absolute totals.
The attribution needs symbols, and what ships is stripped. So the blame stage builds the profiling profile as well: byte-for-byte the same machine code with the symbols left in.
See also
../how-to/run-the-compat-pipeline.md the recipe
adr-0001-tables-not-raws.md why the matrix is a table
adr-0002-two-names-per-crate.md why `particle-core` stays its own crate
adr-0003-compat-checks-start-not-speed.md why nothing is graded on speed
../../gdd.md the portability claim itself
Why content in nihilurk is a table
Audience Anyone deciding how to extend nihilurk, or wondering why
adding a monster is a one-line change.
Prerequisites You have added something, or read
`../how-to/add-a-monster.md`.
This is Understanding, not instructions. Nothing here tells
you what to type.
The short version: nihilurk is an engine that displays and resolves content, and the content is a set of tables. The engine has no opinion about what a dragon is. It knows how to draw a glyph, roll two dice against each other, and ask an entity whether it carries a component. The dragon is a row.
The problem this solves
The default shape of a roguelike codebase is a constructor per monster and a match per item kind:
fn spawn_dragon(world, pos) { /* twenty lines */ }
fn spawn_bat(world, pos) { /* twenty lines */ }
match ring.effect {
RingEffect::Protection => armor += 2,
RingEffect::Strength => power += 2,
...
}
It works, and it rots in a specific way. Every new creature is twenty lines of near-duplicate. Every new ring is an arm in a match, and there turn out to be seven such matches – combat, the HUD, saving, loading, the wand of cancellation, the display name, the throw code. Adding content becomes an exercise in remembering all seven places. The compiler catches some of them and not others, and the ones it misses ship as an item that does nothing.
The failure is not verbosity. It is that the definition of a dragon is smeared across the codebase, so there is no single place to look, and no way to be sure you have found all of it.
The three moves
1. A row is the definition, and there is only one
MonsterDef::row("dragon", 'D', Color::Red, Chase, 8, 12, 2, 10, 2, 10)
.grants(&[Grant::of::<FireImmune>()]),
That line is the entire dragon. Level population reads it, the scroll of create monster reads it, the wand of polymorph reads it, the save file reads it back by name on load. Nothing else in the codebase enumerates species – so there is no second list to fall out of step, and the question “what is a dragon” has exactly one answer.
The same holds for every category. Each kind of item has its own table, and there is one bestiary, one trap table, one loot table.
2. Behaviour is a component the row attaches, not code the row names
This is the move that makes the first one possible.
A ring of protection is not a RingEffect::Protection that seven files have to recognise. It is an item carrying ArmorBonus(2) – the exact component plate mail carries. Combat already folds every ArmorBonus on everything you have equipped, so the ring works before anybody writes a line of ring code. It is not that rings are handled well; it is that there is no ring code at all.
A ring of perception is an item that grants SeesInvisible. The visibility system already asks “does this entity see invisible things?”, because the phantom made it ask. The ring answers a question that was already being asked.
A bow does not know arrows exist. It grants FireArrow. An arrow is a thing that says it answers to FireArrow. They meet at the effect and nowhere else, which is why a sling is one row in each table and no new code.
So the design rule is: when you want new behaviour, first look for a question the engine already asks. Adding a row that answers an existing question is free. Adding a new question is a small, contained change – one component, one registry entry, one place that reads it (see ../how-to/add-an-effect.md).
3. Rarity is data on the row, not arithmetic in the spawner
The loot table could be a chain of ranges:
match rng.gen_range(0..100) {
0..=29 => one(world, rng, pos, SCROLLS),
30..=56 => one(world, rng, pos, POTIONS),
57..=73 => one(world, rng, pos, COINS),
...
}
Adding a category would mean recomputing every boundary by hand and hoping the last arm still reached 99. The percentages would be a constraint the author had to satisfy, and satisfied nowhere in particular.
It is a weighted table instead:
category!("scroll", 300, 1, SCROLLS),
category!("potion", 270, 1, POTIONS),
A weight has no meaning on its own, only against its table-mates, so adding a row cannot invalidate another row. The same two dials – weight and min_depth – appear on monsters and traps, so “how often” and “how deep” are asked and answered the same way everywhere.
Where the pieces live
Every kind of game object – monster, item, trap – is built the same three-part way, and each part has one home:
-
The components (
Trap,Fighter,Renderable, …) go inmodels/src/components.rs. Anything that is an effect — a property a creature holds, fromFireArrowtoBlind— goes inmodels/src/effects.rsinstead, where it gets a registry row, an id in the save file and a lifetime. That file is nouns: everyComponent,ResourceandEventthe game is made of, and nothing about what happens as a result. A system reads them; the save file round-trips them. -
The table and its
Defrow (BESTIARY/MonsterDef, the ten item tables,TRAPS/TrapDef) go in that object type’s own module –monsters.rs,catalog.rs,traps.rs. A row is plain data: a name, a glyph, and the numbers its components need. Row-level dials (weight,min_depth, a trap’ssnare_turns) live here too, so adding a row that wants its own value is still a one-file change – not a trip toconstants.rs. -
The
Bundle– the struct that assembles the components for one spawn (MonsterBundle,TrapBundle) – lives next to its table, not incomponents.rs. It is glue between aDefand the ECS, it names the row type, and it is the one place an entity of that kind is described. Keeping it with the table keeps “how a trap is made” in one file.
So a brand-new object type – say hazards that are neither trap nor monster – is a new module with its table, Def and Bundle, plus whatever new components it needs in components.rs, plus the system that drives it. constants.rs only enters if the type has a game-wide tuning knob, and even then the per-row numbers stay on the row.
The seed contract
Content being data raises a question that content-as-code does not: if the dungeon is generated by drawing on a random number stream, does adding a row move everybody’s dungeon?
It must not, and it cannot. A floor’s layout is a pure function of (seed, depth). Its contents are looser – keyed to the seed, the depth, and the staircase count – but never to the shared GameRng. Each floor gets two private streams:
layout_rng(seed, depth) rooms, corridors, doors, stairs
content_rng(seed, depth, changes) monsters, loot, traps, placement
changes is FloorChanges – how many times this run the player has taken a staircase, portal or trapdoor. GameRng is the live stream the run spends – combat rolls, item effects, traps going off – and nothing that draws on it can reach a floor’s generation.
That buys:
-
Adding content never rearranges the dungeon. Add a monster, triple a drop weight, add a whole category – the walls do not move. Seeds people wrote down keep meaning what they meant. (What the new row can do, of course, is turn up: a bigger table has more in it.)
-
A seed names one set of maps, however you play it. Fight everything on floor 1 or run straight past it, and floor 2 is the same place. Two runs that descend in lockstep meet the same monsters and loot too; what breaks the tie is only the staircase count, never the blow-by-blow.
-
A save does not store the map. It stores a seed, a depth and the staircase count, and the loader rebuilds the exact floor – layout and contents. Reloading mid-run cannot shift the next floor.
-
Climbing back up returns you to the same maze, freshly stocked. The back half of a run – carrying the Element of Yoord out – is a walk through corridors you recognise, but the count has moved on, so
content_rngre-rolls: different monsters, different loot, and the fog of war is blank again (per-floor memory is not kept, to keep the save small). Same place, new problem.
Two players on one seed diverge the moment their staircase counts do. The maps are fixed; the run is not.
models/tests/it/determinism.rs holds the contract to the fire – layout is identical across reloads and however much of the shared stream a run burns; contents match between lockstep descents and differ on a repeat visit. If the layout half ever fails, floor generation has started reading something it should not.
What follows from it
Tools come free. Because content is data, anything that walks the tables works on all of it at once, forever:
cargo run -p nihilurk -- -content lists every name, live
NIHILURK_SPAWN="dragon,bow" spawns any of them
spawn_named(world, name, pos) one door for tests and scripts
models/tests/it/content.rs tests the data as data
None of those has a list of content in it. They read the tables. A row added today is covered by all four today.
Tests get stronger, not longer. every_content_name_spawns_and_keeps_its_name iterates the tables. Adding a monster adds a test case. This is the practical difference between content-as-code and content-as-data: you can write assertions that quantify over all content.
Saves get smaller and more robust. A saved item stores its name, not its components; restore_from_catalog rebuilds the rest from the row. A save that stored a bow’s grant list would only be storing the table twice, and would go stale the moment the table changed.
An unfinished row is visible, and finishing it is a chain. A ring with a name and no chains does nothing, and you can see that at a glance. As a match arm it would be one of several scattered about, indistinguishable from a bug. Finishing it is adding the chain: .power_bonus(2) for increase damage, .grants(&[Grant::of::<Stealthy>()]) for stealth, and so on for regeneration, slow digestion, teleportation and maintain armor. A chain that needs a new marker needs an entry in EFFECTS and a system that reads it; none of them needs catalog.rs to learn what a ring of stealth is.
Where it does not reach
Honesty about the seams, because they are where people get stuck.
Potions, scrolls and wands still need a mechanic. “Restore hit points” is not expressible as a component the engine already folds, so those three categories keep an effect enum and a match (in models/src/items/potions.rs, scrolls.rs and wands.rs respectively). Those matches are exhaustive, the same as traps: no catch-all arm, so a variant given no arm does not build, rather than compiling and shipping as a silent dud.
That buys the missing-arm case, not the missing-behaviour case – exhaustiveness only proves every variant was mentioned, not that what it does is finished. PotionEffect is fully wired (its do-nothing arm, FruitJuice, is deliberate: it’s a taste and a log line, nothing more; Water is the same arm, but only ever reached by a wand of cancellation mutating a carried potion in place – it is not a spawnable row). ScrollEffect is fully wired too; BlankPaper is the one arm left that does nothing, and – like Water – it is never a spawnable row; it only ever happens when a wand of cancellation blanks a carried scroll. What exhaustiveness buys is that nobody can add a new such gap by accident – a new variant has to be named in the match, whether the arm you give it is real behaviour or an honest placeholder.
And one ring did need a verb. Most rings are a number or a marker; the ring of adornment is an event – it fires once, when it goes on, and spends itself doing it. A Grant cannot say that, so it rides as an OnWear component the row attaches, and models/src/items/rings.rs holds the ring verbs (the adornment flourish, the regeneration tick, the teleportitis jump). That file is the honest cost of the design: it is where a ring’s behaviour goes when the row cannot hold it. It still contains no match on RingEffect, and nothing outside it knows which ring is which.
A mechanic has a third home. A condition (confusion, blindness, paralysis, a shifted tempo) is not a potion’s property any more than it is a wand’s: both put the same affliction on the same creature, and both have to know that the player takes it as a marker component the input loop reads while a monster takes it as a MovementType or a slower tempo. So the verbs live in models/src/conditions.rs, one per affliction, and the potion arm and the wand arm are each one line into them.
Numeric modifiers need a place to be read. A new SightBonus is one row in modifiers!, and that row folds it into Loadout for you – but somebody still has to read the field from the calculation it modifies. There is no generic answer to “where does a new number belong”.
Floor budgets are still code. How many monsters and traps a floor gets, and how that scales with depth, lives in populate_level. It is the same for every row, so it is not content – but it is a hand-tuned formula, and it is the thing to change when the dungeon feels wrong.
Content is compiled in. Adding a row means a rebuild. That is a deliberate trade; see adr-0001-tables-not-raws.md.
The test that keeps this honest
If you want one sentence that captures the design, it is the assertion in models/tests/it/content.rs:
for (category, name) in content_names() {
let entity = spawn_named(&mut w, name, at(5, 5)).unwrap();
assert_eq!(&w.get::<Name>(entity).unwrap().what, name);
}
Every piece of content in the game, enumerated, built, and checked, by code that names none of it.
See also
adr-0001-tables-not-raws.md why not JSON files ecs-in-nihilurk.md the ECS half of the same argument ../reference/content-tables.md the tables themselves ../how-to/add-an-effect.md adding a new question the engine asks
How nihilurk’s documentation is written
Audience Anyone adding or editing a page under `docs/`, or
wondering why the page they are reading looks the way
it does.
Prerequisites None.
This is Understanding and a house rule, not a tutorial. It is
*inferred from the pages themselves* — every
convention below is one the existing docs already
keep, written down so the next page keeps it too.
The rules that already existed are in ../README.md: four kinds of document kept strictly apart, and “documentation ships with the change that makes it true”. This page is the layer under that — the shape of a page, and the voice inside it.
Nothing here is aesthetic for its own sake. Every convention exists because these pages are read in a terminal, next to the code, by someone who has a diff open.
The shape of a page
The header block
Every page but docs/README.md opens the same way: a setext title, then a four-space-indented block of aligned keys.
Reference: content tables
=========================
Audience Anyone editing content. Look things up here; do
not read it end to end.
Prerequisites None.
Status Describes the tables as they are in the source.
If this page and the source disagree, the source
is right and this page is a bug.
Audience and Prerequisites are on every page. The third key depends on which of the four kinds you are writing, and it is not decoration — it is the page telling the reader what promise it is making:
| Directory | Third key | What it promises |
|---|---|---|
reference/ | Status | It tracks the source, and the source wins. |
explanation/ | This is | It is reasoning, not instructions. |
how-to/ | (none) | Nothing beyond the task in the title. |
tutorial/ | (none) | Nothing beyond the lesson. |
Values wrap under the key, aligned at column 20. A how-to that needs to point somewhere else does it in the opening paragraph, not by growing a third key.
An ADR is the one exception, and it keeps the shape ADRs have everywhere: Status (accepted / superseded), Audience, Supersedes, Related, and no Prerequisites. It is a record with a lifecycle rather than a page about the code, and the header says which.
The Status formula is load-bearing and should be copied verbatim: “If this page and the source disagree, the source is right and this page is a bug.” It is the sentence that makes a stale reference page a defect rather than an opinion.
Headings
Setext for the top two levels, ### for the third, and nothing below that:
Page title
==========
A section
---------
### A subsection
There is not a single # or ## in docs/. Four levels of heading in a page this size means the page is two pages.
Line width: there isn’t one
A paragraph is one line. Do not hard-wrap prose. These pages are read rendered as often as they are cated, a rendered paragraph reflows to whatever the reader’s window is, and a hand-wrapped one only means the next person to edit a sentence has to rewrap the four lines after it. Nothing checks the width, and nothing should.
What is still line-sensitive, and is therefore left exactly alone: fenced blocks, four-space code blocks, tables, the header block at the top of a page, and the See also block. A bullet is one line too, continuations folded in.
Diagrams
A page that describes a flow — an order things happen in, a stack of contexts, a chain of steps — opens with a Mermaid diagram before the prose that explains it. Horizontal (flowchart LR), so it reads the way the text under it does, and themed so every diagram in the tree looks like the same game.
Copy the theme block verbatim from any existing diagram; it is the terminal’s own palette, which is the only palette nihilurk has:
```mermaid
%%{init: {'theme':'base','themeVariables':{
'primaryColor':'#20242b','primaryTextColor':'#d7dae0',
'primaryBorderColor':'#5c6370','lineColor':'#8a8f98',
'fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace',
'fontSize':'13px'}}}%%
flowchart LR
A[input] --> B[turn] --> C[frame]
classDef hero fill:#3a3418,stroke:#d7ba4a,color:#e8dfa8
classDef peril fill:#3a1f1f,stroke:#c05050,color:#f0c8c8
classDef magic fill:#2f2038,stroke:#a86fc0,color:#e6cdf0
classDef cold fill:#17323a,stroke:#4aa3c0,color:#bfe4f0
```
Four accent classes, and they mean what they mean in the game: hero yellow for the player and their own actions, peril red for damage and death, magic magenta for items and effects, cold cyan for the machinery around them (stairs, the schedule, the renderer). Leave a node unclassed for anything that is none of those.
A diagram is a summary, never the specification — the prose under it still has to say everything. If a diagram needs more than about a dozen nodes, the page is describing two flows.
Code
Indent four spaces. Indentation survives cat, less and a paste into a commit message; a fence does not, and a fence in a terminal is three backticks of noise.
The one exception the corpus keeps is in reference/, which fences a bare declaration — a signature, a struct shape — as a heading for the paragraph that explains it, the way this page’s own example a moment ago did. Those are labels rather than code: they are allowed to elide a parameter list down to world, stdout, screen. Examples, shell commands, table sketches and ASCII diagrams are indented, everywhere, always.
Shell examples carry a comment saying what the command answers:
cargo test --test it content:: # the tables specifically
cargo run -p nihilurk -- -content # what the game knows
Tables
GFM pipe tables, column-padded so the source is readable unrendered. A table earns its place when the rows have the same shape — fields of a struct, keys of a menu, variants of an enum. Three columns is the usual number; past five it is a list wearing a costume.
The See also block
Last section on every page — except a tutorial, which points forward rather than sideways and ends with Where to go next instead. Same shape, different promise. Two-space indent, target then a lower-case fragment saying what is over there, aligned:
See also
--------
content-tables.md the tables these functions read
cli-and-env.md NIHILURK_SPAWN and the -content flag
../how-to/spawn-a-thing.md recipes for the functions above
Paths are relative to the page. Never a bare filename with no gloss: the gloss is the whole point, because it is what tells a reader whether to follow the link.
The voice
Say what a thing is for, not what it is
The house habit, everywhere, is to answer “why would I care” in the same breath as “what is it”:
Projectilemeans two things at once: the missile is spent on what it hits, and nothing can catch it.
not “a marker component indicating projectile status”.
A field’s type is already in the source. What the page adds is the consequence.
Second person, present tense, active
“You change the value in constants.rs and nowhere else.” The reader is a person with a diff open, and the docs talk to them.
The exception is reference/, which describes the program rather than addressing the reader: “pack_rows returns backpack indices.”
Prefer the concrete noun to the abstract one
nihilurk’s docs say “a dragon”, “a red coin at full health”, “a corridor full of kobolds” where a lesser page would say “an entity”, “an unusable pickup”, “multiple targets”. The specific case is what makes a rule memorable, and the rule is usually general anyway.
Name the thing that would go wrong
Most paragraphs that explain a decision end by naming the failure the decision prevents:
Without this, drinking a potion of blindness would be a way to hide.
A test that says a wand rolls
3..=9is not testing the game; it is testingconstants.rs, which needs no help.
If you cannot name what goes wrong without the rule, you may not have a rule.
Bold the rule, italicise one word
Bold opens a paragraph that states a rule (**A full pack is no obstacle.**). Italics fall on the single word carrying the contrast (where, not what). Neither is used for emphasis in general; a page with bold in every paragraph has none.
British spelling in prose, the code’s spelling in code
“Colour”, “armour”, “recognise” in sentences; Color, armor_bonus, recognised inside backticks, because that is what you would grep for. The seam is the backtick.
Dashes
Both -- and — are in use, and both are fine. Do not mix them within a page: pick whichever the page already has and stay with it.
What a page must not do
- Restate a tuning number. Name the constant —
AMMO_BUNDLE_MIN..=AMMO_BUNDLE_MAX, not “3 to 12”. A number copied into prose is a number that goes stale the first time somebody rebalances. The same rule holds for doc comments in the source, and for tests (code-calisthenics.md, “a test never asserts a constant”). - Go unlisted. A page that
../README.mddoes not name is not found. Add a row to its index table, or a line to the ADR list. - Tell history. “Used to”, “was fixed”, “became”, commit hashes and the story of a removed thing belong in an ADR, or in git. A page says what is true now. When a decision is worth reopening, write an ADR in the shape of
adr-0001-tables-not-raws.md. - Paste a list the program can print.
cargo run -p nihilurk -- -contentreads the tables, so it can never be wrong. Point at it. - Be two kinds of document at once. A how-to that starts explaining itself is a how-to and an explanation; split it and link. That rule is in
../README.md, and the ECS pages follow it: a how-to and an explanation, not one page. - Document a deliberate secret. The
Tkey and the-prideflags stay out ofMANUAL.mdand out of../reference/cli-and-env.mdon purpose. Where a page has to mention one — because an engine developer will meet it in the source — it says keep it out of the manual in as many words. - Leave a public item in
modelswithout a doc comment. The crate carries#![warn(missing_docs)], so a build names the item. Say what the thing is for; a field its name already explains needs one short line, and a doc that only repeats the name is worse than none. The voice rules above apply to doc comments too. - Go stale quietly. If a claim can be a test, make it a test and cite the test.
models/tests/it/content.rsexists to hold up the claimshow-to/makes about the tables. - Qualify a path it cannot keep.
`models/src/map/levels.rs`is a claim about where something lives, anddocs_style.shchecks it. A bare`levels.rs`is prose and is not checked — so name the directory when the reader needs it, and drop it once context has established where you are.
Checking a page
Every rule above is checked by one script, so none of this has to be remembered:
./docs_style.sh every page under docs/
./docs_style.sh docs/how-to one directory, or one file
./docs_style.sh --quiet print only what failed
It reports and never rewrites, because where a check fires the fix is a judgement call about where a sentence should break. Ten checks, one per section of this page; its own header comment says which is which, and why a markdown formatter is the wrong tool for this particular corpus.
Exit status is 0 when everything passes, so it drops into a pre-commit hook or a CI step as ./docs_style.sh || exit 1.
See also
../README.md the four kinds of document, and the index code-calisthenics.md the same discipline, applied to the code data-driven-content.md why the docs describe tables, not classes ../../MANUAL.md the player-facing document, a different voice
The ECS in nihilurk
Audience Anyone who has opened `models/src/` expecting bevy
and found something that looks like it half-uses it.
Prerequisites You know what bevy_ecs is for. You do not need to
have used it.
This is Understanding: what nihilurk asks of components,
resources and systems, and why sixteen of its
seventeen schedule steps take `&mut World` instead of
a `Query`. The recipes are
`../how-to/work-with-the-ecs.md`.
nihilurk uses bevy_ecs as a world, not as a framework. There is no App, no plugin, no SystemSet, no change detection, no events in the bevy sense. There is one World, one Schedule of seventeen steps run once per player turn, and a main loop that owns the terminal.
That is a smaller slice of bevy than most projects take, and the parts left on the shelf were left there on purpose.
The three nouns, and what each is allowed to be
Components are pure data
A component says what a thing is. It never says what happens as a result. models/src/components.rs is one file of nouns — every Component, Resource and Event the game is made of, and nothing that reaches into the World. What methods it does carry are about the type itself: Name::article, SpeedKind::faster, GameLog::add. Two shapes recur:
- Markers carry nothing.
Player,Blood,Curse,Confused. Their presence is the fact; a system asksworld.get::<Blood>(e).is_some()and that is the whole check. - Type keys carry one enum naming which one it is —
Potion→PotionEffect. The key is identity for the save file and for identification. It is never a description of behaviour: what a potion does is amatchinitems/potions.rs, and what it is made of is the catalog row.
The rule this buys is the one the whole codebase rests on: a property is a component, never an enum a subsystem has to recognise. A dragon born fire-immune and a player wearing a ring of fire resistance both carry FireImmune, so the wand-of-fire code asks one question and never learns that rings exist. Adding a ring of fire resistance is a table row and no behaviour code at all.
The counterpart is that behaviour lives in tables of data about behaviour rather than in branches: ABILITIES pairs a Grant with a Moment and a fn, one row per ability at whichever moment it fires — a turn ticking by, a blow that lands — and combat::resolve_attack fires the Moment::OnHit rows without ever learning what is in the table. See data-driven-content.md.
Resources are the run’s global objects
Everything singular about a run: the Map, the GameRng, the Depth, the GameLog, the five intent queues, the UI’s modal flags. If there is exactly one of a thing and it is not attached to an entity, it is a resource.
Two habits keep them from becoming a junk drawer:
- A resource with real logic lives with that logic, not in
components.rs.Shake,AutoExplore,FastMove,PackIsOpenandMagicMapRevealare declared in the modules that own them and re-exported, so a call site never has to know which file they came from. - Cosmetic resources are optional.
Particles,Shake,ScoreFlashandFxRngare reached throughget_resource_mut, neverresource_mut, because every test builds a bare world without them. Gameplay code arms a flourish and forgets; it must never require one.
Systems own behaviour in one domain
The schedule is seventeen steps, and each has one job. This diagram draws all seventeen with their real .after() edges; what each step does is ../reference/input-and-turn-loop.md:
%%{init: {'theme':'base','themeVariables':{
'primaryColor':'#20242b','primaryTextColor':'#d7dae0',
'primaryBorderColor':'#5c6370','lineColor':'#8a8f98',
'fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace',
'fontSize':'13px'}}}%%
flowchart LR
PA0["player<br/>action"]:::hero --> SM["smoke"]:::magic --> TE["tick<br/>effects"]:::peril --> RM["reveal<br/>mimics"]:::peril
RM --> SP["spell"]:::magic --> AI["ai"]:::peril
RM --> IT["item"]:::magic --> TH["throw"]:::magic --> AI
AI --> TR["trap"]:::peril --> EQ["equipment<br/>effects"]:::magic
IT --> EQ
EQ --> CO["combat"]:::peril --> RE["reaper"]:::peril --> DL["dungeon<br/>lord"]:::peril
DL --> PA["ability"]:::magic --> SK["sink"]:::peril --> VI["visibility"]:::cold
VI --> SC["score"]:::hero
classDef hero fill:#3a3418,stroke:#d7ba4a,color:#e8dfa8
classDef peril fill:#3a1f1f,stroke:#c05050,color:#f0c8c8
classDef magic fill:#2f2038,stroke:#a86fc0,color:#e6cdf0
classDef cold fill:#17323a,stroke:#4aa3c0,color:#bfe4f0
What each step does, and every .after() edge that holds them in that order, is ../reference/input-and-turn-loop.md — this page is about why the shape is a shape, and repeating the list here would be one more copy to keep true.
The two tail positions are what the diagram is for. ability_system rolls after the monsters move so a ring of teleportation’s jump lands at the top of the player’s next turn — they see the new tile and act from it before anything on the floor moves again, which is the difference between a ring you might keep on and a curse. score_turn_system is dead last so a combo multiplier is applied once the dying is over, never to a number still growing.
Behaviour that does not belong to a schedule step belongs to a module verb called from one — conditions::confuse, equipment::toggle_equipped, items::pick_up. The test for whether something is in the right place is whether you can name the domain it owns in three words.
Why almost everything is an exclusive system
Sixteen of the seventeen take &mut World. Exactly one — visibility_system — is written the bevy way, with Query, Res and Commands.
That split is not laziness, and it is not a migration half-finished. It falls out of what roguelike mechanics actually do.
A Query describes a fixed set of component accesses up front. That is what makes it safe to parallelise and what makes the filters self-documenting. It also means the system has declared, before it runs, every component it will touch.
nihilurk’s mechanics cannot make that declaration. Resolving one thrown wand of polymorph:
- blows a disc open and damages everything standing in it,
- despawns each creature it killed,
- spawns a fresh species in place of each one it transformed,
- sets off every trap and every coin the disc covered, which may teleport a fourth creature, drop a fifth through a trapdoor, and heal the thrower,
- and logs a dozen lines, rolls the shared RNG, and queues particles.
There is no honest tuple of component accesses for that. It touches the archetype graph structurally, mid-resolution, in a way that depends on what the previous step found. Written as a Query system it would be a Query plus Commands plus six ResMuts plus a deferred-command dance to see your own writes — which is &mut World with extra steps and a worse error message.
So nihilurk takes &mut World where mechanics are, keeps Query where a system genuinely only reads and tags, and pays two prices for it:
- The borrow checker is stricter, not looser. Holding the world means every
world.get::<C>(e)conflicts with everyworld.resource_mut(). Five patterns get past that and they are written down in../how-to/work-with-the-ecs.md; they are worth learning once, because they are the whole idiom. - No parallelism. Nothing here needs it. A turn is a sequence by nature — the player moves, then every monster, then the consequences — and there is nothing in it to run at the same time as anything else.
What nihilurk gets back is that a mechanic reads as a procedure. resolve_use works out what the item is, decides where it physically ends up, logs the beat and applies the effect, top to bottom, in one function you can read in one sitting.
If your new system only reads and tags, write it as a Query. The narrow filter is genuinely better documentation than a comment, and visibility_system is the worked example.
Narrow what you fetch
Exclusive systems make it easy to reach for the whole world, so the discipline has to be deliberate. Three rules:
- Ask for actors when you mean actors.
ability_systemwalksOr<(With<Player>, With<Mob>)>, not every entity in the world, because an effect never lands on an item — a ring carriesGrants, and it is the wearer who ends up with the marker. - Fetch nothing the body does not read. A tuple that grows past what a system uses is the first sign it is doing two jobs. A query that carries
Option<&Potion>/Scroll/Wand/Ringfields the body never reads is that sign. - Collect ids, not references. The point of collecting before a mutating loop is to stop borrowing; a
Vec<&Position>has not stopped.
One place still scans the whole world on purpose, and it is worth knowing why. equipment::equipped answers “what is this creature wearing?” by walking every entity and asking whose Equipped.by points at them. Gear points at its wearer rather than the other way round — which is right, because the item system lifts an item out of the pack while it resolves a use, and a ring must not stop working for those few lines, and a monster that caught a thrown dagger has no pack to look in at all. The narrow query that would answer it, Query<&Equipped>, needs &mut World, and every caller holds &World partway through reading something else. The scan is cheap on a floor of tens of entities and allocation-free; if a floor ever held thousands, the fix is an index resource, not a smaller loop.
What the world is not asked to remember
Two whole categories of state are kept out of the ECS, and both for the same reason: they are cheaper to derive than to store.
Terrain is a resource, not entities. Map is one Vec<TileType> plus a bitset of which tiles are unlit. nihilurk never had one entity per tile, and a floor-sized crop of entities (WIDTH by HEIGHT) with a Renderable each would cost more to iterate every frame than the whole rest of the world put together.
Nothing cosmetic is saved. Bloodstains, corpse marks, smoke, live particles, the shake, the scorekeeper’s flash and FxRng are all rebuilt empty on load. The three map-sized overlays alone would come to 2.2 KB — more than the entire save file they would be joining — and none of it is gameplay. A reloaded floor is the floor you left, scrubbed of the mess you made on it. The map and the message log are out for the same reason; saveload.rs lists all four exclusions at the top.
The related rule is that cosmetic randomness comes off FxRng, never GameRng. A firework’s colour drawn from the gameplay stream would mean every flourish quietly reshuffled the dice for everything after it, and models/tests/it/determinism.rs exists to catch exactly that class of mistake.
See also
../how-to/work-with-the-ecs.md the recipes, and the borrow patterns data-driven-content.md why behaviour is a table, not a branch code-calisthenics.md the shape this code is held to ../reference/components.md every component, resource and event ../reference/input-and-turn-loop.md the schedule and the main loop
The feel layer
Audience Anyone adding an effect and wondering what it should
look like, or wondering why a wand of striking gets a
white flash and a wand of drain life gets a magenta
one.
Prerequisites None. `../reference/content-tables.md` if you want to
know which row produced the thing being described.
This is Understanding: what nihilurk spends its cosmetics on, and
what each one is trying to tell the player. The dry
facts — which row, which field — are the reference.
nihilurk is a terminal game with sixteen colours and one glyph per tile. Almost everything it can say to the player, it says in a line of text. The feel layer is the part that does not: a spark where a blow landed, a beam racing down a corridor, a map that lurches when something hits hard enough to deserve it.
None of it is gameplay. None of it is saved. Every bit of it can be switched off (-nb, -nshake) and the game underneath is identical. It exists because a roguelike turn is a wall of text, and a wall of text does not tell you which of the eleven things that just happened was the one that nearly killed you.
%%{init: {'theme':'base','themeVariables':{
'primaryColor':'#20242b','primaryTextColor':'#d7dae0',
'primaryBorderColor':'#5c6370','lineColor':'#8a8f98',
'fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace',
'fontSize':'13px'}}}%%
flowchart LR
T(["a turn resolves"]):::hero --> Q["systems queue<br/>Particles + arm Shake"]:::magic
Q --> P["play_particles<br/><i>blocks</i>"]:::magic
P --> M["play_magic_map<br/><i>blocks</i>"]:::magic
M --> R["render"]:::cold
R --> S["play_shake<br/><i>never blocks</i>"]:::magic
S --> N(["next key"]):::hero
classDef hero fill:#3a3418,stroke:#d7ba4a,color:#e8dfa8
classDef peril fill:#3a1f1f,stroke:#c05050,color:#f0c8c8
classDef magic fill:#2f2038,stroke:#a86fc0,color:#e6cdf0
classDef cold fill:#17323a,stroke:#4aa3c0,color:#bfe4f0
The rules
Gameplay arms a flourish and forgets it. A mechanic calls fx.hit_spark(x, y) or shake::kick_shake(world, ShakeKind::Heavy) and moves on. It never waits, never checks, and never learns whether anything was drawn — the effect resources are reached through get_resource_mut, so a headless test world with no renderer in it runs the same code path.
Cosmetic randomness comes off FxRng, never GameRng. A firework’s colour drawn from the gameplay stream would mean every flourish quietly reshuffled the dice for everything after it, and models/tests/it/determinism.rs exists to catch exactly that.
Anything that could leak information is gated on sight. A shake for a blast in a room the player has never entered tells them there is a room there. helpers::player_sees is the shared check.
A missile in flight holds the screen until it lands. An arrow, a dagger, a thrown wand — a flight is the one animation in the game that is cause rather than aftermath, and an impact spark going off while the arrow is still two tiles out reads as two unrelated things instead of one. So Particles::hurl and Particles::lob advance the batch’s hold by their own span, and every mote queued behind them — the spark, the blast, the corpse fling, the second arrow — opens once the missile has finished drawing. It is the effect layer’s own bookkeeping, so the rule above still stands: gameplay arms its flourish and forgets it, and no caller passes a delay.
The one thing that must never cost a keystroke is the shake. Particles and the magic-map wipe block input, the way NetHack freezes for a bolt — they animate an aftermath the player asked for by swinging. A shake is armed by the dungeon, at exactly the moments a player is typing ahead. See ../reference/rendering.md, “The screen shake”.
What a scroll looks like
Each one has a flourish of its own. The enchantments and monster confusion throw Particles::spark_burst off the reader’s tile — orange for a plus biting into gear, magenta for a charm settling onto a pair of hands (and again on the victim when the next blow spends it); food detection uses the same burst in green. Scare monster, hold monster and sleep flash Particles::condition_mark over each creature they caught (!, #, z), staggered a beat apart so a roomful reads as a wave crossing the room rather than every tile blinking at once — and the lasting news is the renderer’s status tint under the creature, which holds for as long as the condition does.
What a wand looks like
A zapped bolt (fire_bolt → Particles::beam) is a line of suns (☼) — one glyph the whole way, not NetHack’s directional - | \ /, which at one cell per tile read as scenery rather than as something travelling — crawling a cell every 90ms so it is watchable at the default AnimRate. Each cell flickers white/its own colour twice rather than fading once, and lands with Particles::impact_sparks — a brighter flash on the hit tile plus a small ring of offset sparks around it, timed off beam’s returned flight time so they pop right as the bolt arrives. Flashier and denser than the beam alone, on every bolt-type wand (fire, cold, lightning, magic missile, striking, drain life).
Teleport away/to (teleport_entity_away, teleport_target_here) leaves a Particles::poof and a short map::Smoke puff (helpers::VANISHING_SMOKE_TURNS) where the creature stood — the same magenta signature the teleport trap and the scroll of teleportation now leave. A swap (wands::relocate) puffs the same magenta poof on the tile each side lands on, and no smoke. Polymorph (polymorph_entity) puffs the same smoke in a small ring around the transformed creature’s tile (leave_smoke_ring), staggered so it reads as smoke rolling outward.
Polymorph draws once from the bestiary and takes what it gets. When that happens to be the species it started from, the magic still worked — the log says the creature “twists and warps into a different-looking <name>!” and the dungeon moves on. Re-rolling until the species differs would be a loop whose exit depends on the table being long enough, which is a hang waiting for the day somebody shortens it.
Every particle animation’s frame pacing (a zap’s beam, a blast’s ripple, a teleport’s poof, the magic-mapping reveal wipe) scales with the AnimRate resource, set once at startup from -anim-rate — see ../reference/cli-and-env.md.
Thrown
Thrown, a wand only bursts on impact — hitting a creature or a wall, or flying its full throw_reach leash. Lobbed into open floor short of that, it just lands with its charges and its secret intact.
In flight it doesn’t fly as its own catalog glyph the way every other thrown item does (Particles::hurl) — a wand tumbles as a mystic grenade instead (Particles::lob), a slower arc tinted with the BlastPalette its charges are about to burst in (BlastPalette::accent_color), so the colour of the coming blast is already legible while it’s still in the air. The lob is deliberately the slow half of the beat; the burst it ends in is not.
On impact (resolve_wand_throw) it spends every remaining charge at once. The blast animation is coloured per wand (blast_palette → particles::BlastPalette) and always opens on that palette’s bright first frame — a primary blast never reads as dark. On a light or utility wand’s throw (never an attack wand’s — it returns before the per-creature loop), every creature the blast actually caught also gets a small, darker Particles::secondary_burst a beat after the primary ripple passes its tile — purely cosmetic, confirming who the effect landed on, no gameplay of its own.
Fire and cold blasts (zapped or thrown — both run through the one elemental_blast) also billow a grey/white Particles::smoke_burst across the blast cells a beat after the flames. Fire’s smoke additionally lingers on the map for SMOKE_LINGER_TURNS (4) real turns afterward, DCSS-style — the map::Smoke resource, ticked once a turn by smoke_system, ages every puff down and the renderer draws a grey ≈ over any smoky tile currently in view (never on a tile an actor stands on, like blood). Cold’s puff is animation only; nothing persists.
Anyone elemental_blast kills outright is finished off right there (combat::finish_indirect_kill) rather than left for reaper_system’s next sweep, so their death burst knows the blast’s centre and flings the corpse radially outward through where they stood — an edge casualty gets launched straight on away from the blast, not a random direction like an ordinary indirect kill. A casualty standing exactly on the blast’s own centre has no “outward” to speak of, so it falls back to the usual random fling.
- Attack wands (fire, cold, lightning, magic missile, striking, drain life —
is_attack_wand) throw the wide grenade:GRENADE_RADIUS,GRENADE_DIE_PER_CHARGEsides per remaining charge, armour-ignoring. Fire and cold carry their element (immunities apply); the rest are non-elemental. - Wand of light throws the same wide grenade, but instead of an element it dazzles every creature caught (
dazzle) — a monster flips toMovementType::Confused, the player gains theConfusedcondition. “dazzle” in the log. - Utility wands (polymorph, haste, slow, teleport away/to, cancellation) throw a small
BLAST_RADIUSblast that deals no damage — the effect is the whole payload, worked on every creature caught (apply_thrown_wand_effect), thrower included. - Wand of swapping throws the same small blast and shuffles everyone it caught, thrower included, into each other’s tiles (
shuffle_places). - Wand of nothing: bursts in magenta/cyan confetti particles, no blast.
Effects that can land on the player (via a thrown blast): polymorph logs “You feel like a new person”; haste/slow set the player’s Speed; dazzle gives Confused; teleport away runs the scroll-of-teleportation relocation; teleport-to with no other target logs the “straight to yourself” joke; either teleport with nowhere to put a monster — no open tile left on the floor, or no room at the zapper’s side — bursts it in a spray of gore instead of fizzling (burst_in_transit, which goes through finish_indirect_kill like any other death with nobody swinging); cancellation is cancel_player — zeroes every PowerBonus/ArmorBonus on weapons and armour, turns unread scrolls to BlankPaper and potions to Water, and lifts every Curse without destroying the item.
Doing it with style
The one moment in a run that exists purely to be looked at. A ring of adornment is worth exactly one action — put it on and it doubles the run’s score and disintegrates — and the climb out with the Element of Yoord calls the same code, because a run is not scored on what it killed, it is scored on how it left.
do_it_with_style is sixteen Particles::firework blasts over a BlastPalette::Glam burst on the player’s own tile — the eight tiles around them, then Frost Nova’s star points a few tiles further out, 90ms apart so the chain runs round them rather than flashing at once, each in a colour drawn at random from magenta, cyan and yellow (rings::GLAM_COLORS). Twice the fireworks and twice the glam of the old eight-blast version, and the one animation in the game allowed to take its time. Plus a ShakeKind::Heavy kick, four lines of fanfare, and score::double.
It is queued like any other animation, so the engine plays it out before the victory panel rather than instead of it.
Why none of this is unit-tested
Everything on this page is checked by playing the game, and nothing on it is checked by cargo test. That is a decision, not a backlog.
Coverage of this layer — which shake a blow earns, how a shake decays and what outranks what, the corpse-fling and bone scatter, mote birth and culling, the map-cell clipping a shake pushes tiles through, the log line’s colours and the pride stripes, an end-to-end test that renders a frame and asserts glyph rows — has the same two problems. The decision, and what it deleted, is adr-0004-no-tests-of-presentation.md.
It asserts the wrong thing. A shake is punctuation: what matters is whether it reads as heavier than the one below it, and no assertion can tell you that. A test can tell you ShakeKind::Heavy lasts 260 ms, which is a number you can also read off ShakeKind::shape in less time than the test takes to compile. What it cannot tell you is the thing you actually want to know, which is whether the heavy one feels heavy.
It breaks for reasons that are not bugs. Presentation tests are coupled to presentation, so moving a layer, renaming a badge or reordering the HUD fields turns them red without anything being wrong. An end-to-end render test is the worst of them: its fixture has to hand-mirror main’s startup resources, so it drifts every time startup changes and tells you nothing when it does.
The rule: assert game rules, not their presentation. A test earns its place here if it pins something a player could be cheated by — damage arithmetic, what a seed generates, what survives a save, what a menu offers. If the worst case of it being wrong is “that looked a bit off”, it belongs in a playtest.
One rule on this side of the line does clear that bar, and it is the exception worth knowing: log_panel holds the log’s --MORE-- prompt back until the effect layer is empty, because play_particles reads any keypress as “skip” and a prompt asking for Space mid-animation asks for exactly that key. A trick shot’s blast is queued behind the missile’s flight, so the key eats the explosion and leaves the flight looking fine — the player is cheated of the thing they lined the shot up for, and the frame it happens in looks perfectly correct, which is the one case playing the game does not catch. Two tests in view.rs pin it, on a fixture of two resources.
Two things in the effect layer keep their tests, and the exceptions are worth knowing because they are not about feel:
particle-core’s own tests, includingtests/parity.rs. That crate isno_std, andf32::ceilhas two implementations — the hosted intrinsic and a hand-rolled branch. If those disagree, ano_stdbuild animates differently from the game.parity.rsruns both branches undercargo test.models/tests/it/determinism.rs, which cares about the effect layer only to prove it stays out of the way — that cosmetic randomness comes offFxRngand never perturbsGameRng.
Neither is testing how anything looks.
See also
../reference/content-tables.md which row produces each of these ../reference/rendering.md the layers, and the three playback loops ../reference/components.md the shake table, and what arms each kind ../how-to/add-an-effect.md adding a property, and giving it a look adr-0004-no-tests-of-presentation.md why none of this is unit-tested
nihilurk documentation
This is the documentation for people who want to put things in the dungeon: monsters, items, traps, magic. It lives in the repository, in plain text, and reads in a terminal.
cat docs/how-to/add-a-monster.md
Start here
| I want to… | Read |
|---|---|
| Add my first item, hand-held | tutorial/add-your-first-item.md |
| Add my first monster, hand-held | tutorial/add-your-first-monster.md |
| Add my first spell, hand-held | tutorial/add-your-first-spell.md |
| Add my first body, hand-held | tutorial/add-your-first-body.md |
| Give a monster a mind, hand-held | tutorial/give-a-monster-a-mind.md |
| Add a monster | how-to/add-a-monster.md |
| Add a new playable body, like the lurk | how-to/add-a-body.md |
| Add a potion, wand, weapon, ring | how-to/add-an-item.md |
| Add an active spell | how-to/add-a-spell.md |
| Add a trap | how-to/add-a-trap.md |
| Add a property like “fire immune” | how-to/add-an-effect.md |
| Add a way for a creature to think | how-to/add-a-rule-set.md |
| Add one reflex to that thinking | how-to/add-a-rule.md |
| Make something rarer, or deeper | how-to/tune-rarity-and-depth.md |
| Add a whole new kind of item | how-to/add-an-item-category.md |
| Put a specific thing on a specific tile | how-to/spawn-a-thing.md |
| See a change working in the real game | how-to/play-through-a-pty.md |
| Look up a field, a type, a default | reference/content-tables.md |
| Look up a component, resource, event | reference/components.md |
| Change a balance number | reference/constants.md |
| Look up a function I have to call | reference/spawn-api.md |
| Look up a flag or an env var | reference/cli-and-env.md |
| Look up a conduct, or what a win is paid | reference/conducts.md |
| Look up how input and the turn loop work | reference/input-and-turn-loop.md |
| Look up what a monster does with its turn | reference/agents.md |
| Look up how a frame gets to the screen | reference/rendering.md |
| Reach an entity and change it | how-to/work-with-the-ecs.md |
| Understand why it is built this way | explanation/data-driven-content.md |
| Understand how bevy_ecs is used here | explanation/ecs-in-nihilurk.md |
| Understand why monsters think in rule sets | explanation/agents.md |
| Know what an effect should look like | explanation/the-feel-layer.md |
| Write or edit a page in here | explanation/documentation-style.md |
| Know which pages to touch after a change | how-to/update-the-docs.md |
| Know what good numbers look like | explanation/combat-and-balance.md |
| Know what shape to leave the code in | explanation/code-calisthenics.md |
| Check it still runs on a Pi | how-to/run-the-compat-pipeline.md |
| Know how portability is tested | explanation/cross-platform-testing.md |
| Cut a release, one command and a push | how-to/cut-a-release.md |
| Publish a release to crates.io | how-to/publish-to-crates-io.md |
| Publish a GitHub release | how-to/publish-a-github-release.md |
| Publish the documentation site | how-to/publish-the-site.md |
| Know why package names differ from directories | explanation/adr-0002-two-names-per-crate.md |
And architecture decision records. They keep the history the other pages leave out: what was tried, what was rejected, and when to reopen it.
explanation/adr-0001-tables-not-raws.md content is compiled in, not loaded from JSON
explanation/adr-0002-two-names-per-crate.md package names differ from library names
explanation/adr-0003-compat-checks-start-not-speed.md the matrix checks that it starts, not that it is fast
explanation/adr-0004-no-tests-of-presentation.md presentation is checked by playing
explanation/adr-0005-aim-resolves-before-the-mobs-move.md a zap and a throw resolve before the mobs move
explanation/adr-0006-effects-saved-by-id.md effects are saved by string id
explanation/adr-0007-aggravation-is-a-component.md aggravation sits on top of the tactic
explanation/adr-0008-player-actions-are-queued-intents.md the player's step is a queued intent
How this is organised
Four kinds of document, kept strictly apart, because a person adding a monster at 1am and a person deciding whether to fork the project need very different pages.
tutorial/ A lesson. Follow it start to finish and you will have added something that works. It teaches; it does not cover every option.
how-to/ A recipe for one real task. Assumes you already know your way around. Short, imperative, no theory.
reference/ The dry facts. Every table, every field, every default, every function signature. Never a narrative.
explanation/ Why the code is shaped the way it is. Read when you are deciding something, not when you are typing.
If a page starts to be two of these at once, split it.
How a page is written — the header block, the width, the voice, the things a page must not do — is explanation/documentation-style.md, inferred from the pages already here.
Who this is for
Every page names its own audience and prerequisites in a header. Broadly:
Content author Adds monsters, items, traps. Edits tables. Needs to
know Rust well enough to copy a line and change the
numbers. Lives in tutorial/ and how-to/.
Engine developer Adds systems, changes how content is spawned or
resolved. Starts at explanation/ecs-in-nihilurk.md and
how-to/work-with-the-ecs.md, then lives in
reference/ and explanation/.
engine/ itself (input handling, rendering) is
thinner ground than models/: its tests
cover key dispatch (update.rs) and the log gate
and help-key remap (view.rs), not what a frame looks
like, so a change to a frame is checked by playing
the game, not by cargo test. See reference/input-and-turn-loop.md
and reference/rendering.md.
The game design document (../gdd.md) is a third thing again: what nihilurk is trying to be. It is not a spec of the code.
The crates
The code is a workspace of small libraries and one binary. Pages name files by their path from the repo root, so this is the map. The directory names are the short ones below; the packages are nihilurk-models, nihilurk-strings, nihilurk-particle-core and nihilurk (for engine/), and cargo -p takes those. See explanation/adr-0002-two-names-per-crate.md.
models/ The game. Rules, content tables, ECS systems, save
files. No input, no drawing. Almost everything in
tutorial/, how-to/ and reference/ lives here.
strings/ Every player-facing sentence, one file per language,
chosen by a lang-* feature at build time.
particle-core/ The particle arithmetic. no_std, no dependencies.
engine/ The binary. Key handling, the turn loop, Screen
(the cell grid and its diff) and every layer that
paints into it. Also
nihilurk-dispatch, which picks a language binary.
compat/ A test rig, outside a bare cargo build and
cargo test. See how-to/run-the-compat-pipeline.md.
Dependencies run one way, toward the leaves: engine uses models and strings; models uses strings and particle-core. Nothing depends on engine. models re-exports its public API from the crate root, so use models::*; reaches it. helpers is private bar four names (Hit, apply_hit, chebyshev, mob_at), and so is anything marked pub(crate). A page that names one of those says so.
The rule that keeps this true
Documentation ships with the change that makes it true. A pull request that adds a field, renames a table, or changes what a row means is not finished until the pages that describe it say so. Stale documentation is worse than none, because someone will believe it.
Three things make that cheap here:
-
The docs describe tables, and the tables are short. There is rarely more than one page to touch.
-
models/tests/it/content.rsenforces the claims this documentation makes about the tables – unique names, reachable rows, depth gating, no catalog row spawning a dud – andmodels/tests/it/determinism.rsenforces the one claim that protects everyone else’s saved seeds: adding content cannot move a wall.engine/tests/workspace.rsis the third of that kind: it checks that a barecargo teststill covers every crate that ships. If a doc claim can be a test, make it a test and cite it here. -
cargo run -p nihilurk -- -contentprints the live content list. It reads the tables, so it can never go stale. Prefer pointing a reader at it over pasting a list into a page.
Quick sanity check
./docs_style.sh these pages, house style
cargo test # everything still works
cargo test --test it content:: # the tables specifically
cargo test --test it determinism:: # seeds still mean what they meant
cargo test -p nihilurk --test workspace # the root test run still covers everything
cargo run -p nihilurk -- -content # what the game knows
NIHILURK_SPAWN="dragon" cargo run -p nihilurk # put one in front of me
And the compatibility pipeline, which is slower and answers a different question:
./compat_test.sh # does it still run on a Pi?