Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

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 expectHere
Resting and searchingYou cannot rest, search or pass a turn. Stairs are the only recovery that is not magic.
A food clockNo hunger. The Dungeon Lord’s patience runs out floor by floor instead.
Experience levelsNone. e.g. Nihil gets stronger by finding things and the lurk by killing them.
Unidentified itemsYou know what each one is the moment you find it. Only gear hides its quality.
ShopsSpirits give items away or trade them. There is no money.
A start-up menuNone, as in Rogue. Your name and your body are arguments.
PetsYou can only have one Helper pet that follows you around, other charmed creatures won’t carry through floors
WandsYou can zap one, or throw it to spend every charge at once.
GoldA 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:

FlagEffect
-c, --centeredCentre the map on you instead of using the fixed viewport.
-ns, --no-saveWrite no save file.
-nb, --no-bloodNo bloodstains, no corpse-and-bones death animation.
-nshake, --no-shakeNo screen shake.
-mouse, --mouseMouse on. Left click looks at a tile, right click travels there. On an aiming reticle left moves, right moves and fires.
-nobones, --no-bonesA death writes no bones file and an ascent reads none.
-nrec, --no-recordingDo not record this run. Every new run writes recordings/NAME-SEED.rec in the data directory.
-v FILE, --replay FILEPlay back a recording. Nothing is saved, scored or dumped, and bones are off. A run resumed from a save has no recording.
-endless, --endlessNo Element of Yoord spawns, so you cannot win. The dungeon keeps going down.
-anim-rate N, --anim-rate NScale animation hold time, 0.1 to 5.0. Raise it if your terminal redraws slowly.
-keymap FILE, --keymap FILERemap 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, --contentList every name NIHILURK_SPAWN accepts, and exit.
-scores, --leaderboardPrint the ten highest scores, and exit.
-h, -help, --helpPrint 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 QUIK on 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:

SymbolMeaning
.Room floor
▒Corridor
#, ``
+Door
>Stairs down
<Stairs up
@You
A letterA 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:

DirectionArrowsvi keysNumpad
NorthUpk8
SouthDownj2
WestLefth4
EastRightl6
Northwesty7
Northeastu9
Southwestb1
Southeastn3

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:

KeyAction
Shift + directionRun 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
oExplore the floor automatically
AToggle picking up useful items during auto-explore
OChoose a destination and travel there
TabFight the weakest visible foe automatically (one already beside you comes first); throws a boomerang or moon blade in your hand
fFire the launcher in your hand, or throw the boomerang or moon blade in it
vMake a reach attack with a suitable weapon, or throw the boomerang or moon blade in your hand
F1Show the key list
F2Hide 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:

KeyAction
aUse an item
tThrow an item
dDrop an item
eEquip something you can wear or wield
qQuaff a potion
rRead a scroll or a rune
zZap a wand
wWield a weapon or launcher
WWear armour
PPut 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

KeyAction
> 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
ZOpen the spells menu
Alt+Q W E RCast spell one to four directly
xSwap between a melee weapon and a launcher (one turn). With a menu or cursor open, close it instead
XClose the current menu
EscBack out of a menu; on the bare map, quit after confirmation
Ctrl+CQuit 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. Stealthy is the lurk’s in the same sense Venomous is the rattlesnake’s — which is to say, not really. Both are just components a Grant can 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 a Backpack or (for something worn from the first turn) also run it through equip_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 -content listing, 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_EFFECTS cannot 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 -content listing, 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 a Backpack; the player’s copy of it lives only in Spellset.
  • Try to throw it (t). There is nothing to select – the throw menu only ever lists Backpack contents, and a spell was never in one.
  • Save and reload. Your Magic total survives, and so does your Spellset – models/src/saveload.rs round-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 the EFFECTS registry and an effects::lend to put it there – see ../how-to/add-a-spell.md for the two that already do.
  • Give a monster the same trick. SpellEffect is a shared mechanic – ice_bolt doesn’t care who user is – and a monster casts from its spellset out of its own Magic. 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_blast for a blast, crate::conditions for a status – the same reuse a wand’s own mechanic leans on.

  • A spell is not an item, deliberately: no Position, no Item, no pack slot, no NIHILURK_SPAWN, nothing to throw or drop. It lives in a Spellset, 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.

  1. Add the variant.

    pub enum Body {
        Nihil,
        Lurk,
        Wraithkin,                       // new
        Monster(&'static MonsterDef),
    }
    

    Body::name() needs an arm too — it is what -b matches against and what an error about the body prints.

  2. Write wear_wraithkin, next to wear_lurk. It inserts the starting Renderable, Fighter, Magic and Speed the same way, and grants whatever innate effects the body is born with through grant_all — a const &[Grant] slice, the same shape LURK_GRANTS uses, so it can be handed to grant_all unchanged. Pull every number out to its own constants::wraithkin module (see ../reference/constants.md below) rather than writing them inline — that is the one line wear_lurk itself would fail review on if it didn’t.

  3. Wire it into wear:

    Body::Wraithkin => wear_wraithkin(world, player),
    
  4. 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 own Lurk effect 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 the EntitySave note in add-an-item-category.md.

  5. Teach innate_tempo if the body doesn’t return to SpeedKind::Normal after a staircase lifts whatever the floor lent it (the lurk always returns to Quick).

  6. Teach equip_refusal if the body can’t wear everything nihil can. The lurk refuses every slot but Slot::Finger; a monster body refuses everything unless its bestiary row carries ItemUser. Say why in the refusal string — it’s what the player reads when they try.

  7. Wire the flag. engine/src/main.rs matches ("-b", "nihil") / ("-b", "lurk") literally; add ("-b", "wraithkin") => body = Some((models::Body::Wraithkin, "-b")). -b and -am already refuse being passed together and refuse an unrecognised name — nothing else to touch there.

  8. brings_a_pack. Only Nihil returns true. 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:

Appendix: quick check

  1. Add the Body variant in models/src/body.rs, and its arm in Body::name().
  2. Write wear_<name> next to wear_lurk, with every number in a new constants::<name> module.
  3. Add the Body::<Name> => wear_<name>(world, player) arm in wear.
  4. Give it a save marker: an effect already in EFFECTS that nothing else puts on the player.
  5. Teach innate_tempo if the body does not return to SpeedKind::Normal.
  6. Teach equip_refusal if the body cannot wear everything, and say why in the string.
  7. Add the ("-b", "<name>") arm in engine/src/main.rs.
  8. Leave brings_a_pack returning false.
  9. Copy models/tests/it/body.rs and lurk.rs into a new test file named for the body: glyph, dice, grants, save round trip.
  10. Run that test with cargo test --test <file>, then cargo test.
  11. 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

  1. Open models/src/monsters.rs and find BESTIARY.

  2. 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
    
  3. Build and look at it:

    cargo build
    NIHILURK_SPAWN="basilisk" cargo run -p nihilurk
    
  4. 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),

See tune-rarity-and-depth.md.

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 Aggravated in a row. It is a fifth MovementType, 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

  1. Copy the closest row in BESTIARY (models/src/monsters.rs) and change it.
  2. Give it a unique lowercase name, a stat band from “Choosing the numbers”, and a min_depth.
  3. Chain extras only if you need them: .grants(..), .casts(..), .invisible(), .weight(..).
  4. Pick a colour from the sixteen in the palette, and never write Aggravated as the movement.
  5. Never rename an existing row; saved monsters look their species up by id (the name).
  6. Run NIHILURK_SPAWN="<name>" cargo run -p nihilurk and look at it.
  7. Run cargo test --test it content::.
  8. 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

  1. Write pub static <NAME>: RuleSet in models/src/agents.rs; the order of rules is the behaviour.
  2. Set leashed: true only for a set that hunts the player.
  3. Append a MovementType variant at the end in models/src/components.rs, and add its arm to rule_set_for.
  4. Fix every other match on MovementType the compiler names.
  5. Put the variant in the movement column of a bestiary row.
  6. Test each rule the set lists: one case where it wins, one where an earlier rule passes.
  7. Run cargo test -p nihilurk-models --lib agents and NIHILURK_SPAWN="<monster>" cargo run -p nihilurk.
  8. 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

  1. Write fn <name>(p: &Percept) -> Option<Action> in models/src/agents.rs; read only the percept, and take chance from p.roll.
  2. Name it as a pub const <NAME>: Rule, with a one-line “fires when” doc comment.
  3. List it in a rule set (add-a-rule-set.md).
  4. Test both sides on a hand-built percept(...): once where it fires, once where it passes.
  5. Run cargo test -p nihilurk-models --lib agents.
  6. 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.

  1. Append a variant to SpellEffect in models/src/components.rs – at the end, never in the middle (see the warning below):

    pub enum SpellEffect {
        DragonBreath,
        // ... more variants ...
        GateDown,
        IceBolt,
    }
    
  2. Add the row in SPELLS (models/src/catalog.rs):

    SpellDef { effect: SpellEffect::IceBolt, name: "Ice Bolt", cost: 2, range: 6, kind: SpellKind::Attack },
    

    cost is Magic points, multiplied by constants::spells::TURBO_MAGIC_COST_MULT when a staff (TurboMagic) is wielded and kind is SpellKind::Attack, never for a SpellKind::Skill. cost is also calibrated against constants::player::START_MAGIC and sits in one of four Magic-cost tiers, 1 through 4; the rows already in SPELLS show where each tier lands. range feeds the aiming reticle exactly like a wand’s Ranged, and is meaningless – leave it 0 – 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 its match and in its all array. cargo test will 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.

  1. 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_effect over WandEffect – 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_mult is constants::spells::TURBO_MAGIC_POWER_MULT when a staff multiplied the cost, 1 otherwise – fold it into your damage roll (multiply the final number, the way every existing Attack arm does; see sting, thunderbolt, force_lance for 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_strike all call elemental_blast (items/wands.rs), the same function a zapped or thrown wand calls. Pick a radius (BLAST_RADIUS for a zap-sized disc, GRENADE_RADIUS for a thrown-sized one), a damage roll, and an Element (or None for non-elemental).
    • A line – force_lance traces get_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) and thunderbolt (a plain armour-ignoring roll, like a wand bolt) both just call crate::helpers::monster_at(world, target) and roll once.
    • Everything in view, no target needed – circle_of_death and frost_nova call crate::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_hit with a Hit::magic or Hit::elemental rather than writing to a Fighter’s hp yourself – 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.

  2. 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 way WandEffect::needs_target carves 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.

  1. 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’s false list (models/src/components.rs) unless you are building the rare aimed skill (Polymorph Other is the one today: kind: Skill with a range, so a staff does not double its cost; treat target as step 3 of “Create an attack” would).

  2. 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::conditions for anything that changes the caster’s state (cure_one_condition, hasten, paralyse, …) the way cure_self and haste_self do, or lending a marker component through effects::lend the way magic_ward lends MagicWard for Lifetime::Floor and bide lends Bided for Lifetime::NextAction. A skill that plants something on the floor (Setup) reaches into crate::traps instead – see setup for spawning a revealed trap and springing it immediately if something is already standing there.

    power_mult is still a parameter every arm takes, by convention (every arm in the match has the same signature) – a Skill is free to ignore it, since spell_system never doubles a Skill’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. SpellEffect derives Serialize, and a save encodes a variant as its position in the enum – including inside a saved Spellset (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.

Spellset does survive a save – models/src/saveload.rs round-trips it like every other piece of the player. So do the two marker components spells have left behind so far, MagicWard and Bided – but not through a field of their own: each is a row in the EFFECTS registry (models/src/effects.rs, "magic_ward" => MagicWard;) lent through effects::lend, and EntitySave::effects saves it by id with the Lifetime it is being held for. If your new spell leaves a marker of its own lying around outside Spellset, 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 Spellset that its Magic can pay for, at random each turn, and fires it if it can (../reference/agents.md, the cast rule). To give a species your spell, chain .casts(n, &[SpellEffect::YourSpell]) on its bestiary row – that is all the dragon’s Fireball is. Only an Attack spell 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

  1. Append a variant at the end of SpellEffect in models/src/components.rs.
  2. Add the SpellDef row to SPELLS in models/src/catalog.rs: cost, range (0 for a skill), kind. List it in every_spell_has_a_catalog_row (models/tests/it/creature_casts.rs).
  3. Attack: write the arm in apply_spell_effect (models/src/items/spells.rs), and land damage through apply_hit with Hit::magic or Hit::elemental.
  4. Attack that hits everyone in view: add it to SpellEffect::needs_target.
  5. Skill: add it to the needs_target list unless it is aimed, then write the arm, borrowing a scroll if one already does the job.
  6. A marker the spell leaves behind: add it to EFFECTS and lend it through effects::lend.
  7. Give it to a species with .casts(n, &[SpellEffect::<Name>]); only a ranged attack is ever cast that way.
  8. Try it: put it in the starting Spellset in initialize_world, press Z, then revert that edit.
  9. Run cargo test --test it spells::.
  10. 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

  1. Append a variant to TrapEffect (in components.rs):

    pub enum TrapEffect {
        ...
        Dart,
        Pit,        // <- at the end; see the save-format warning
    }
    
  2. Add the row to TRAPS (in traps.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. weight is how often the dungeon lays this one against the others it could lay; min_depth is the first floor it appears on. Every existing trap is 10 and 1, which is to say equally likely from the start.

  3. 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.

  4. 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 Teleport and Trapdoor do.

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. victim is whoever moved onto the tile. Write the log line through the seen / is_player pair 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_trap bursts it over the 3x3 around its tile: your arm then runs once per creature caught, none of whom is standing on the trap. So read victim’s own Position if you need where they are, take trap_pos as where the trap was, and do not assume the two are the same tile. The burst’s own damage (TRICK_SHOT_* in constants::traps) is dealt before your arm runs, and the trap entity is already despawned by then – an arm must never despawn it itself. spring_trap handles 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 an Entity across 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 – see arrow_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 TrapEffect variants; 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::pick walks 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

  1. Append a variant at the end of TrapEffect in models/src/components.rs.
  2. Add the TrapDef row to TRAPS in models/src/traps.rs: weight, min_depth, and snare_turns if it holds.
  3. Write the arm in apply_trap_effect; log through seen and is_player, and subtract total_armor_plus only.
  4. Write the arm in trap_flourish, or an empty one that says why.
  5. Run cargo build, then cargo test --test it content::.
  6. Run NIHILURK_SPAWN="<name>" cargo run -p nihilurk.
  7. Fix the docs: the TrapEffect line 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

  1. 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;
    

    Default is not optional – it is how a Grant attaches the thing without knowing its type.

  2. 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."],
    
  3. 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>()] },
    
  4. 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 u64 bitset 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 EFFECTS half-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.rs catches 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. action runs 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 returns false and stays silent.
  • Moment::OnHit { glancing, lethal } – the attacker’s magic doing something to what it just hit. effect is the marker on the attacker, action runs 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.

  1. Add a row to the modifiers! table in effects.rs, naming the component and the Loadout field 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) implementing Modifier, a Loadout.sight_bonus field, and the line of Loadout::absorb that sums it – so the single-pass fold cannot leave one of your modifiers out. The rows are the only place a modifier is named.

  2. Attach it from rows the way ArmorBonus is attached – see RingDef::armor_bonus and insert_modifier, which skips a zero so an inert row costs nothing.

  3. Read it where it applies:

    equipped_total::<SightBonus>(world, entity)   // one number
    loadout(world, entity).sight_bonus            // already taking the fold
    

    Either 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 sixth equipped_total call there would be a sixth walk over the same handful of items.

    This is the step no framework can do for you: PowerBonus is read in combat, ThrowBonus in 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

  1. Marker: add a unit struct deriving Component, Default, Clone, Copy in models/src/effects.rs.
  2. Add one "stable_id" => Type row to EFFECTS, and never rename the id later.
  3. Hand it out with .grants(&[Grant::of::<Type>()]) on a monster, ring or launcher row.
  4. Read it with a With<Type> filter, world.get::<Type>(e), or grant.probe(world, e).
  5. Acts on its own: add one row to ABILITIES in models/src/abilities.rs, with its Moment.
  6. Fires once on wear: attach OnWear from the row.
  7. Number: add one row to modifiers!, attach it from rows, and read it with equipped_total.
  8. Nothing may cancel it: add its id to IDENTITY_EFFECTS.
  9. Run cargo test, spawn something that grants it, save, reload, and check it is still there.
  10. Fix the docs: one row in the EFFECTS table 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:

  1. Rebuild it from the row. Extend restore_from_catalog in catalog.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.

  2. 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 in save_game and load_game, and bump SAVE_VERSION.

EntitySave field order is the save format. Postcard encodes fields positionally. Append; do not insert or reorder. Any change to a saved struct needs a new SAVE_VERSION: the test the_save_layout_only_changes_with_the_version fails until it has one, then until you regenerate models/tests/golden/save.bin (its message says how). The release is then a minor one, and release/bump.lua refuses 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

  1. Add the Def struct in models/src/catalog.rs and the component it attaches in models/src/components.rs.
  2. Implement ItemDef: name, spawn, and spawn_as_loot, weight or min_depth only if you need them.
  3. Write the table as a pub const with #[rustfmt::skip].
  4. Add one category!(..) line to DROPS in models/src/spawn.rs.
  5. Behaviour: hook the component into item_system and a file under models/src/items/.
  6. Save: extend restore_from_catalog, or append a field to EntitySave and bump SAVE_VERSION; never insert one.
  7. Worn gear with a hidden quality: run enchant_equipment at spawn and let KnownQuality gate it.
  8. Run cargo run -p nihilurk -- -content | grep -A3 '^<category>' and NIHILURK_SPAWN="<name>" cargo run -p nihilurk.
  9. Run cargo test, then cargo test --test it saveload::.
  10. 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

CategoryTableRow alone?Also needs
WeaponWEAPONSyes–
ArmourARMORSyes–
CoinCOINSyes–
AmmoAMMOyes**a launcher effect to answer to
LauncherLAUNCHERSyes**an effect to grant
TreatTREATSyes–
RingRINGSnoa RingEffect variant
PotionPOTIONSnoa PotionEffect variant + a mechanic
ScrollSCROLLSnoa ScrollEffect variant + a mechanic
WandWANDSnoa WandEffect variant + a mechanic
DeckDECKSyesa new card: a CardFace variant, a CARDS row + an arm in items/decks.rs
RuneRUNESnoa 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.

  1. Append a variant to RingEffect in models/src/components.rs:

    pub enum RingEffect {
        ...
        MaintainArmor,
        FireResistance,      // <- at the end. See the warning below.
    }
    
  2. 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.

  1. Append a variant to the effect enum in models/src/components.rs – PotionEffect, ScrollEffect or WandEffect.

  2. Add the row in POTIONS, SCROLLS or WANDS.

    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’s range feeds the aiming reticle; a wand always spawns with constants::wands::WAND_CHARGES, full.

  3. 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.rs
    

    A 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_effect is over TrapEffect – 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 KnownQuality says 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

  1. Find your category in “At a glance”.
  2. Weapon, armour, coin, ammo, launcher, treat: add one row to its table in models/src/catalog.rs.
  3. Ring: append a RingEffect variant at the end, then add the RingDef::new row.
  4. 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_effect or apply_rune_effect.
  5. A wand with no reticle (it acts on the room or the zapper): add it to WandEffect::needs_target.
  6. Never insert or reorder an enum variant; a save stores its position.
  7. Run cargo run -p nihilurk -- -content | grep '<name>'.
  8. Run NIHILURK_SPAWN="<name>" cargo run -p nihilurk, then cargo test --test it content::.
  9. 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 (@2 to @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.
  • o explores and picks things up, and any key pressed during it stops it. Put @2 after 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, x to 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

  1. Pick a version no tag has used. Bump every crate that changed, and the version = "..." on each path dependency that points at it:

     engine/Cargo.toml    version, and models = { ..., version = "X.Y.Z", ... }
     models/Cargo.toml    version
    

    engine/tests/workspace.rs fails if a path dependency has no version at all. It cannot tell you the version is stale, and cargo cannot either until the upload has half happened.

  2. Check the page crates.io will show. Description, keywords, categories, readme and rust-version are frozen into each version, so a wrong one costs a new number. engine/tests/workspace.rs holds the limits crates.io enforces (five keywords, twenty characters each), and fails on an integration test that reads outside its crate without an exclude line, because that test cannot pass from an unpacked download. The floors differ by crate: nihilurk-particle-core and nihilurk-strings build on 1.85, nihilurk-models and nihilurk need 1.91 (let chains and a const TypeId::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).

  3. 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 see publish-a-github-release.md first.

  4. Run the three commands under Quick commands, in order. A dry run that says aborting upload due to dry run for 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.

  1. Does nihilurk still run everywhere?

  1. Run the pipeline:

    ./compat_test.sh
    
  2. 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  runs
    

    The 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)
    
  3. runs exits 0. no shell and does not run fail the pipeline, because they mean a machine that is supposed to run nihilurk cannot.

  4. 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.

  1. 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.

  1. 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 linux row must be a -musl triple. A -gnu binary 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

  1. 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.rs does for all hundred-odd rows. Use it whenever the thing itself is the subject – its components, its name, its glyph.

  2. 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 is w.query_filtered::<Entity, With<Player>>().single(&w).

  3. 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_world hands the player a starting kit – ring mail, a mace, a short bow – and that kit is already on. Slot::Body and Slot::Hand hold one thing each, so equip_silently returns false for 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_pack in models/tests/it/identify.rs is the pattern.

  4. Name it in a literal and let it panic. MonsterDef::named("dragon") and TrapDef::of(TrapEffect::Bear) panic on a miss, which is what you want from a typo in a test. Save lookup for 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:

  1. 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 Map and both can fail. The refusal branch is not optional – a sealed-in player is a legal floor.

    These two are inside models only (crate::helpers and crate::traps, neither re-exported). From a test or from engine/, either pick the tile yourself against Map::blocks or call spawn_list, which does the searching for you.

  2. Roll from GameRng, in a scoped block. The live run rolls from GameRng and never from layout_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
    
  3. 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_monster is the only way a monster should enter the world: it attaches the row’s granted effects and its Invisible marker. TrapBundle::random is the rolled version, but it wants a ChaCha12Rng of its own – floor generation hands it a content_rng. From a live system, name the trap you mean.

  4. 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” – and article_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 spawnYou getNot
a wandBattery { charges: WAND_CHARGES }, full, same as a dropa battery rolled off constants::wands
a weapon, armour, a ringno bonus, no Cursean enchantment roll
arrows, quarrelsStack { count: 1 }a bundle
a trapTrapReveal::Sight, so you can see what you placeda rolled reveal style
a monsterthe row, effects and invisibility includedanything 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

  1. Look at it now: NIHILURK_SPAWN="<name>" cargo run -p nihilurk, with names from -content.
  2. In a test: spawn_named(&mut w, "<name>", Position { x, y }).unwrap() on a bare World.
  3. Needs a floor: build the world with initialize_world, as models/tests/it/identify.rs does.
  4. In the pack: stow(&mut w, player, item). Worn: force_unequip the incumbent, then equip_silently.
  5. From game code: find a free tile, roll from GameRng in a scoped block, then spawn through spawn_monster, spawn_named or TrapBundle::from_def.
  6. Many at once: spawn_list, which tracks occupancy.
  7. Loot as the dungeon rolls it: roll_item(world, &mut rng, depth, pos).
  8. Run cargo test --test it content:: and cargo 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 from content_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_depth is 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

  1. Decide which question you are asking: how many per floor, which category, which row, or allowed here at all.
  2. Per floor: edit populate_level in models/src/map/population.rs; this moves every row at once.
  3. Category: change its weight in DROPS (models/src/spawn.rs).
  4. Row: .weight(n) on a monster, or weight on a trap; twenty is the baseline (monsters and traps; spirits sit at 1, the floor) and items have no per-row weight.
  5. Gate: raise min_depth; it is a hard gate, and nothing makes a row stop appearing deeper.
  6. Run cargo test --test it content::.
  7. Count what a draw gives you with a throwaway loop over roll_item or MonsterDef::pick.
  8. Fix the docs: if you changed a DROPS weight, update the weights table in ../reference/content-tables.md and 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 changedTouchBecause
A monster, weapon, armour, coin, ammo, launcher or treatnothing, unless it brings a new mechanic (see below)-content prints every row
A ring../reference/components.md, the RingEffect linethe enum is pasted there
A potion../reference/components.md, the PotionEffect linethe 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 listone table row per wand; a thrown wand blasts by kind
A spellnothing, 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 linethe enum is pasted there
An effect../reference/content-tables.md, one row in the EFFECTS tablethe table has one row per id
An abilitynothingthe 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.6each says what the bodies are
A rule../reference/agents.md, the rules tableone table row per rule
A rule set../reference/agents.md, the sets tableone 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 listingeach 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 carriesthe table and the components are the register
A constants:: module../reference/constants.md, one row in the modules tablenothing else checks it
A component, resource or event../reference/components.mdit is the register
A system in the turn schedule../reference/input-and-turn-loop.md, the schedule listthe order is the page
A keyMANUAL.md, “Useful commands” and the quick reference; doc/nihilurk.6, “GAME CONTROLS”; ../reference/input-and-turn-loop.md, the key tablethree places list keys
A flag or an env var../reference/cli-and-env.md; doc/nihilurk.6; print_help in engine/src/main.rsthe -h text is separate from the page
A player-facing sentencea new function in strings/src/en.rssee “Player-facing text” below
The install line, the languages or the license../../site/llms.txtit repeats them for AI crawlers
A new page../README.md, one row in the index; a See also blockan 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

  1. 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”.
  2. Search for what you renamed. docs_style.sh checks paths and links, not identifiers. After a rename or a removed mechanic, run grep -rn 'old_name' docs MANUAL.md doc.
  3. 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.rs plus a row in the EFFECTS registry, so a table can name it with Grant::of::<C>() and a wand of cancellation can strip it. Follow add-an-effect.md instead.
  • A number gear contributes — a weapon’s die, a ring’s plus. That is a Modifier in effects.rs; equipped_total already 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. See add-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:

  1. Derive no more than it needs. Component always. Clone, Copy when it is small and gets read out by value. Serialize, Deserialize only if the save stores the type itself rather than its fields — most do not.
  2. 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 new SAVE_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.
  3. 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 fn that touches the World. If the component needs a verb, the verb lives in the module that owns the domain — conditions.rs for an affliction, equipment.rs for 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’s revealed latch, a wand’s charges.
  • Despawning between floors. map::levels::transition_level clears everything with a Position that 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 Renderable gets it on screen, but when it draws is a layer in engine/src/view.rs’s render, and the order matters — a later layer covers an earlier one. See ../reference/rendering.md.
  • Documenting. A row in ../reference/content-tables.md for the table and one in ../reference/components.md for 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_attack rolls 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’s damage_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 call helpers::took_damage by 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:

  1. A marker component in components.rs, doc-commented with what it costs the player and what it does to a monster instead.
  2. A verb in conditions.rs that does the player/monster split once.
  3. 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 in conditions::afflicted.
  4. An arm in conditions::clear_player_conditions, unless it is meant to survive a staircase — Plated and Forged are the only two that do, and the staircase settles them instead.
  5. A field appended to saveload::EntitySave, plus the save and load lines and a new SAVE_VERSION. A condition a save forgets is a condition that silently lifts on reload.
  6. 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 that Entity — a Backpack, a queued WantsToThrow — now holds a dangling id, so take it out of the pack first (items::scrolls::lift_curses shows the shape).
  • An item picked up does not despawn: it loses its Position. An item dropped gets one back. “Is it on the floor?” is With<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,
});
IntentQueueDrained by
WantsToAttackAttackQueuecombat_system
WantsToUseUseQueueitem_system
WantsToThrowThrowQueuethrow_system
WantsToCastSpellQueuespell_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, never GameRng. A colour drawn from the gameplay stream would mean every firework quietly reshuffled the dice for everything after it. score::random_bright and helpers::spill_blood both 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 — SeesInvisible might 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 a Query system 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

  1. Before a new component, check it is not an effect, a modifier, or a row of an existing kind.
  2. Component: add it to models/src/components.rs as nouns only, decide and say if it is saved, and add its row to ../reference/components.md.
  3. New kind of entity: a Def row type and table, a Bundle, then spawn_named and content_names.
  4. Damage: combat::resolve_attack if somebody swung, helpers::apply_damage if not; the reaper finishes the dead.
  5. 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.
  6. Gear stat: equipped_total::<C>, or loadout for several; probe the marker to ask a yes or no.
  7. Spawn: spawn_named for a row, roll_item for a drop; remove an item from the pack before you despawn it.
  8. Input: push an intent onto its queue and return; the matching system drains it.
  9. System: register it in models/src/schedule.rs with an explicit .after(), and explain the edge in ../reference/input-and-turn-loop.md.
  10. Cosmetics: use get_resource_mut, roll from FxRng, and gate anything that leaks on player_sees.
  11. Run cargo test --workspace, cargo clippy --all-targets, cargo fmt --all, and cargo 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
  1. Gates. Dead (0 HP), Asleep, Petrified or out of energy: no turn.
  2. Percept. perceive builds it (below).
  3. Rule set. rule_set_for(movement_type, helper, ally).
  4. 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, or Action::Wait.
  5. Action. act carries it out. A Strike is 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 when leashed(&percept, set) is true. A step onto a foe queues a blow on AttackQueue; 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:

MobOff-view action
carries Aggravated { tx, ty }one step straight toward (tx, ty), never leashed; waits once there
is the player’s Helperthe 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

FieldTypeWhat it holds
atPositionWhere the mob stands.
in_viewboolThe mob’s tile is in the player’s view.
player_atPositionWhere the player stands.
noticedboolIn view, and within STEALTH_RANGE if the player is Stealthy.
pinnedboolPinned, Rooted or Clamped: strikes, but never steps or shoots.
swimsboolDeep water is floor to it.
launcherboolA launcher is drawn in its hand.
spellsetVec<SpellEffect>Each spell in its Spellset that its Magic can pay for.
rollu32A die rolled for this turn off the seed’s GameRng, so a seeded run replays it.
helperboolIt is the player’s Helper.
allyboolIts Faction is Ally: its spells never go where they would catch the player.
aggravatedOption<Position>Where an Aggravated mob is heading.
foesVec<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&MapThe floor.

Sighting is { who: Entity, at: Position, is_player: bool }.

Action

VariantCarried 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.
WaitNothing. The turn is not spent.

The rules

Each is a const Rule wrapping a plain function of the percept.

RuleFires whenAction
CASTThe 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
SHOOTA 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
STRIKEA foe on one of the eight tiles around it, bar one across a corner (across_corner).Strike it, the player before anyone else
CLOSE_INAny foe: the player once noticed, or an ally of theirs.Step along the shortest walk to the nearest
HEELMore than one tile from the player.Step along the shortest walk to a tile next to them
FLEEThe player is among the foes.Step straight away from them
STAGGERAlways.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

SetPicked whenleashedRules, in order
STILLMovementType::Staticno(none)
CHASERMovementType::Chase (and the retired Aggravated)yesCAST, SHOOT, STRIKE, CLOSE_IN
ALLYChase on a Faction::Ally that is not a Helper (a charmed monster)noCAST, SHOOT, STRIKE, CLOSE_IN
AMBUSHERMovementType::AmbushnoSTRIKE
FLEERMovementType::FleenoFLEE
STAGGERERMovementType::ConfusednoSTAGGER
HELPERthe mob is a Helper, whatever its variantnoCAST, 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

PieceFile
Percept, Action, Rule, RuleSet, the rules, the sets, think, rule_set_formodels/src/agents.rs
perceive, act, the gates, energy and roundsmodels/src/ai.rs
MonsterDef::casts (a species’ spells and Ma)models/src/monsters.rs
can_afford_spell, pay_for_spellmodels/src/items/spells.rs
Aggravatedmodels/src/components.rs
blast_cellsmodels/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 -.

FlagEffect
-s <seed>, --seedStart the run from a specific u64 seed. Reproducible.
-c, --centeredCentre the map on the terminal instead of on top left.
-ns, --no-saveNo save. The run is never written to disk.
-nb, --no-bloodNo 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-shakeNo 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, --mouseTurn 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-bonesSkip 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, --endlessNo 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-recordingSkip 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 FILEPlay 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, --contentPrint every name the content tables know, then exit.
-scores, --leaderboardPrint 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>, --keymapRemap 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>, --bodyPlay as nihil (the default) or lurk. See below.
-am <species>, --as-monsterPlay 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 as Normal and the only creature in the game born at that tempo. Two monster rounds bought per three player turns; shown as QUIK on 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 ItemUser mark – 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 .sav appended, 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>.sav in 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 _, so a b saves as a_b.sav and ../x cannot 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 -content works – 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].

    PieceExampleEffect
    cursedcursed ring mailAdds the Curse tag: once worn or wielded it will not come off until a scroll of remove curse.
    megacursedmegacursed ring mailAdds Curse and MegaCurse: 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 protection is 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. x0 gives 1, and anything over the limit gives the limit.

    A curse and a minus are independent: -2 long sword is not cursed, and cursed +2 long sword is. 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
ValueLevel
battlefieldBattlefield
labyrinthLabyrinth
vaultVault
bee, beeworld, bee worldBee World
castleCastle
islandIsland

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.

ValueStyle
row, rows, rowbyrow, row-by-row, curtainRow by row
spiral, swirlSpiral
explode, explosion, blast, burstExplode

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):

OSData directory
Linux, the BSDs$XDG_DATA_HOME/nihilurk, or ~/.local/share/nihilurk
macOS~/Library/Application Support/nihilurk
Windows%APPDATA%\nihilurk
  • A relative XDG_DATA_HOME is 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 for identify.rs and the save file; it is never behaviour. The catalog row turns a key into the components that do something; the mechanic is a match on 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

ComponentDataOnSaved?
Namewhat: Stringmonsters, items, heroyes
Playermarkerthe heroyes
Positionx, y: u16anything on the floor; removed while in a Backpackyes
Renderableglyph: char, color: Coloranything drawnyes (colour packs to one byte against a 16-entry palette)
Factionenum Player / Monster / Ally / Spirits / Inertevery actoryes

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.

TypeDataOnSaved?
StartingBodyResource, Bodywhich body the next spawned player wakes up in; read once by map::initialize_worldnot saved directly — see below
MonsterBodyComponent, &'static MonsterDefthe player, only when wearing a speciesyes, as the species id (SaveGame::monster_body), not read back from Name
Lurkeffect marker (crate::effects)the player, only as a lurkyes — 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

ComponentDataOnSaved?
Mobmovement_type: MovementTypeevery monsteryes
Fighterhp, max_hp, armor, power, max_power, armor_bonus, power_bonus: i32every actoryes
Bloodmarkercreatures that bleedtransient — re-attached to the player and every mob on load
Magicpoints, max_points: u8the heroyes
IceCubemarker — 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 killyes (ice_cube field)
ColdSlainmarker — 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)transientno
Helpermarker — the player’s boon companion, a Faction::Ally that follows them between floorsat most one ordinary one, plus any number with PriorityHelperyes (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.

VariantRule set
StaticSTILL — never acts.
ChaseCHASER
FleeFLEER
ConfusedSTAGGERER (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.
AmbushAMBUSHER

A Helper thinks with HELPER whatever its variant. A charmed Chase monster (a Faction::Ally without Helper) thinks with ALLY.

ComponentDataOnSaved?
Aggravatedtx, ty: u16 — where the noise came from; out of the player’s view the monster beelines thereevery monster on the floor, after a scroll of aggravate monsters or a ring’s shriekyes

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.

ComponentDataOnSaved?
Speedkind: SpeedKind, energy: i32every actorkind yes; energy transient (resets to 0)

SpeedKind — enum, saved by variant order (Slow, Normal, Fast, Quick — Quick last on purpose; Normal is #[default]):

MethodReturns
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

ComponentDataOnSaved?
Viewshedvisible_tiles: Vec<(u16,u16)>, revealed_tiles: FixedBitSet, range: u16, dirty: boolthe hero (and anything that needs sight)revealed_tiles + range yes; visible_tiles + dirty transient
Hiddenmarker — not drawn or announced right nowout-of-view monsters, undiscovered traps, unperceived invisiblestransient (visibility rebuilds it; traps clear it on reveal)
Invisiblemarker — intrinsically unseeable without SeesInvisiblethe phantom, and the invisibly-stashed floor item a floor hides at population::HIDDEN_ITEM_CHANCEyes
Spottedmarker — inside the player’s viewshed this turnanything currently seentransient
Detectedmarker — drawn on the map even out of view, in DarkMagentawhatever a potion of magic / monster detection turned upyes

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

ComponentDataOnSaved?
Itemmarker — can be picked upevery itemyes
Valueamount: i32 — score, paid the moment it is picked uptreasure coins, the relicyes
Pickupeffect: PickupEffect, amount: i32 — works where it lies and is gone; never carriedevery coinyes
Backpackitems: Vec<Entity> — inventory orderactors that carryyes
Consumemarker — used up on usepotions, scrollsyes
Batterycharges: i8wandsyes
Stackcount: u8 — how many share one slotammunition, treatsyes
Treatfor_item_users: bool — thrown as an offer of loyalty, never usedsnack, fancy of peacerebuilt from the row on load
Rangedrange: i32 — feeds the zap reticlewandsyes
Amuletmarker — the Element of Yoord; carrying it inverts the staircasesthe relicyes

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_help gates it — a red coin at full health stays on the floor, and autoexplore::known_item_tiles skips 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

ComponentKey enumMechanicSaved?
PotionPotionEffectitems/potions.rskey yes
ScrollScrollEffectitems/scrolls.rskey yes
WandWandEffectitems/wands.rs; thrown, items/throwing.rskey yes
RingRingEffectnumbers + Grants from the RingDef row; the three with verbs are items/rings.rskey yes
RuneRuneEffectitems/runes.rs; charged: bool says whether it still holds a usekey and charged yes
Cursemarkerequipped-and-stuck until remove curse destroys it, or a scroll of enchantment burns it offyes
MegaCursemarkerrides with Curse on a MEGACURSED item; nothing lifts it or destroys the item but a suit taking the killing blowyes
Vorpalbane: Stringany blooding hit slays bane (or any VorpalTarget) outrightyes
KnownQualitymarkerthis 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 outyes

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.

MethodReturns
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.

ComponentDataMeaningSaved?
ThrownDamagei32die rolled on impact; no component ⇒ bounces off harmlesslycatalog
LaunchedDamagei32die rolled instead, once LaunchedBy firescatalog
Projectilemarkerspent on what it hits, never caughtcatalog
Piercingmarkerruns the whole aimed line, hitting everyone in itcatalog
Returnsmarkerthrown, 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 onecatalog
ChainHitsu8thrown 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 homecatalog
LaunchedByGrantthe effect a launcher must grant to switch this missile to its LaunchedDamage diecatalog
Launchermarkera bow / crossbow / blowgun — no attack die, enchant lands on ThrowBonuscatalog

“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:

ComponentDataMeaningSaved?
Reachi32strikes this many tiles out instead of adjacent (bardiche 2, whip 5)catalog
ReachPiercingmarkerthe strike runs the whole line instead of stopping at the first body (bardiche yes, whip no)catalog

Components — player conditions

ComponentDataMeaningSaved?
Confusedmarkerplayer-only stumble (a monster uses MovementType::Confused); blocks fast-move / auto-explore / auto-fight; HUD CONFyes
Blindmarkerplayer-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 BLNDyes
Paralyzedmarkerfor 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 PARLyes
ConfusingTouchmarkerhands 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 GLOWyes
Platedcountthe 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 PLxNyes
Forgedcountthe 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 FOxNyes
MagicWardmarkerthe 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 WARDyes
Bidedmarkerthe 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 BIDEyes

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.

ComponentDataMeaningSaved?
Trapeffect: TrapEffect, reveal: TrapReveal, revealed: boola ^ entity; revealed latches once knownyes
EntityMovedmarkerchanged Position this turn — trap_system checks its tiletransient (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

ComponentDataOnSaved?
Scorevalue: i64the heroyes

Every change to it goes through models/src/score.rs, which is the whole scoring table in one screen:

WhatWorthWhere
A creature diesKILL_PER_MAX_HP × its max_hpcombat::pay_for_the_corpse, from both the melee path and finish_indirect_kill
More than one dies in a turnthe turn’s kills together, ×(1 + COMBO_BONUS_PER_KILL per corpse past the first)score::Combo, settled by score_turn_system
A staircase is usedSTAIR_PER_TIER × (difficulty tier + 1)map::levels::award_stair_score
A ring of adornment goes ondoubleditems::rings::wear_adornment
Treasure picked upits Valueitems::pick_up → score::award
The run is wondoubledmap::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 / queueFieldsDrained by
WantsToAttack → AttackQueueattacker, targetcombat_system
WantsToUse → UseQueueuser, item, target: Option<Position>, slot_idx: Option<usize>item_system
WantsToThrow → ThrowQueuethrower, item, target: Position, slot_idx: Option<usize>throw_system
WantsToCast → SpellQueueuser, effect: SpellEffect, target: Positionspell_system
PlayerAction → PlayerActionQueueStep(StepPlan) (a step, blow, lunge, kick or struggle), Stairs, Drop, WilledTeleport, Charge, Reach, each decided at the keyplayer_action_system

All five queues are transient (empty at save time).

Resources

UI and input state — all transient, Default on load

ResourceFieldsNotes
HelpMenuopen: boolthe F1 key list. Any key closes it and does nothing else.
ItemInfoopen: 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.
CommandBarhidden: boolF2 hides the yellow command bar; the log and player line move left into its columns.
QuitPromptopen: bool, warned: boolthe “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.
PackIsOpenopen, mode: PackMode, selected, action_mode: Option<usize>, action_selectedpack modal cursors; selected and action_mode are backpack indices, not row numbers. pack.rs.
PackModeenum: Browse Use Throw Drop Equip Quaff Read Zap Wield Wear PutOnwhich key opened the pack, and therefore its title, its rows, and what picking one does. See below.
AutoPickupenabled: bool (default true)the A toggle: whether auto-explore detours for loot. autoexplore.rs.
LeftBehindprivate: the items turned down, the room the player is inwhat 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.
TargetingStateactive, item: Option<Entity>, throwing: bool, cursor_x, cursor_y: i16aiming reticle; throwing swaps the range to whatever throw_reach gives the item and confirm to a hurl.
PlayerTempofast_parity: boolthe player half of the speed system: a Fast turn flips it, monsters move only when it flips back.
AttackQueue / UseQueue / ThrowQueue / SpellQueueVec<…>see Events above.
Shakeenabled: bool (-nshake), plus a private kind + agescreen 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.

TypeKindDataSaved?
Alignmentcomponent, heroi8, -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
SpiritsHostileresourcebool; set for good when Alignment hits a pole or the player lands a direct hit on a peaceful spirityes
SpiritKindcomponentCacodaemon / Eudaemon; which way the spirit pulls Alignmenttransient (from MonsterDef::spirit_kind)
SpiritEventcomponentfn(&mut World, Entity, Entity); what melee does to ittransient (from MonsterDef::spirit_event)
TestOfFaithLedgercomponentmarker; the angel’s blessing is pending until the next staircaseno
OfferMenuresourceopen, selected, options: Vec<OfferOption>, source: Option<Entity>; the “choose one of three” menu, rolled once on opentransient
OfferOptionenumSpell / Weapon / Armor / Ring; one row of that menutransient
BarterMenuresourceopen, demon, column: BarterColumn, cursor, player_side / demon_side and player_selected / demon_selected: Vec<Tradeable>transient
BarterColumnenumPlayer / Demon; which column the cursor is intransient
TradeableenumItem(Entity) / Spell(SpellEffect); one thing on either side of a bartertransient

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:

ModeKeyShowsPicking a row
Browseieverythingopens the ItemAction::MENU modal
UseaeverythingItemAction::Use
ThrowteverythingItemAction::Throw
DropdeverythingItemAction::Drop
Equipeanything with EquippedItemAction::Use
Quaffqanything with PotionItemAction::Use
Readranything with Scroll, Deck or RuneItemAction::Use
Zapzanything with WandItemAction::Use
WieldwEquipped { slot: Hand }ItemAction::Use
WearWEquipped { slot: Body }ItemAction::Use
PutOnPEquipped { 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:

ShakeKindArmed byShape
Hitthe 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 moving80 ms, 1 cell — a tick
Killcombat::kill_shake, from resolve_attack and finish_indirect_kill, when the player can see the victim’s tile120 ms, 1 cell — short
Heavyeverything 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_nova260 ms, 2 cells — medium
Woundedhelpers::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

ResourceFieldsSaved?
GameLoghistory: 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!”)
Depthwhat: u8 — current floor, 1-basedyes
FloorChangescount: u32 — staircase/portal/trapdoor traversals this run; salts content_rng so a repeat visit re-stocks the same layoutyes
PlayerNamewhat: Stringyes
Killsby_name: BTreeMap<String, u32> — every corpse the score paid for, under the name it died with; read by the run dumpyes
DungeonLordidle_turns: u32 — turns lingered on this floor; at DUNGEON_LORD_PATIENCE a portal openstransient (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.

ConductBreaks when the playerSite
Potionsdrinks a potionresolve_use, models/src/items.rs
Scrollsreads a scrollresolve_use
Wandszaps a wand, or throws oneresolve_use; resolve_wand_throw, throwing.rs
Runesinvokes a charged runeresolve_use (an inert rune does not count)
Spellspays for a spellpay_for_spell, items/spells.rs
Coinstakes a coinspend_pickup, items/pickups.rs
Rangedthrows or fires anythingresolve_throw, items/throwing.rs
Attackinglands a melee swing, lunge or reachland_swing, combat.rs
Charmingcharms a creaturecharm, companion.rs
Companionrecruits a boon companionrecruit, companion.rs
Damageloses HPtook_damage, helpers.rs; the angel’s test of faith
Gearputs gear ontoggle_equipped, equipment.rs
Armorhas armor take a killing blowarmor_takes_last_hit, helpers.rs
Lurkis not the lurknot 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

WhereWhat
SaveSaveGame::conducts, the broken mask. Added with a SAVE_VERSION bump.
LeaderboardEntry::conducts, the kept mask (zero for a loss). LEADERBOARD_VERSION 2.
Win screenOne conduct::MARK per kept conduct, from conduct::marks, under the score.
Run dumpA -- Conducts (+N%) -- section on a win, one name per kept conduct.
-scoresThe 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::HoldsRead when you want to change
combatExcellent-hit odds and dice, the chip-damage floor, the odds a corpse keeps each piece of gear, a shattered cursed suit’s splintershow swingy a fight is
playerStarting HP / armour / power / magic, sight radiusthe hero’s opening position (new games only)
lurkStarting HP / magic (-b lurk), GROWTH_CHANCE and GROWTH_STEP for feeding off a killhow the lurk starts and how fast it grows
progressionFINAL_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
mapWIDTH, HEIGHT, dark-room chancethe playfield (see the determinism caveat below)
populationMonster / 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
potionsWhat 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 eatshow much a potion swings a run
scrollsWhat one enchantment is worth (and that a minus is mended whole), how long sleep and hold last, how often sleep backfires on the readerhow strong the room-clearing scrolls are
decksDECK_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
runesPROTECTION_TURNS, RECHARGE_STEP, RECHARGE_CAPhow long a rune of protection holds and how much one of recharging gives
trapsArrow / 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_CHANCEhow much a trap hurts and how fast it scales
wandsWAND_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
lootEnchantment odds (normal / exceptional / cursed), the bonus ranges, ammo bundle size, the improvised-throw diehow the drop table feels
itemsTHROW_RANGE, LIGHT_THROW_RANGE, LAUNCHER_RANGE, STACK_LIMIT, PACK_CAPACITYreach and pack density
ringsSTEALTH_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_MAXthe two rings with a number that isn’t on their row, and what a numeric ring rolls
scoreKILL_PER_MAX_HP, COMBO_BONUS_PER_KILL, COMBO_PRIDE_CHANCE, STAIR_PER_TIER, SCORE_FLASH_TURNSwhat the run is scored on, and how loudly
monstersDEFAULT_SPAWN_WEIGHTthe baseline rarity a bestiary row gets
spiritsALIGNMENT_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
helpersACCEPT_CHANCE, SHAPESHIFT_CHANCEhow often a thrown treat wins a Helper, how often a dog’s or mirror hound’s kill changes its shape, once
spellsSPELLSET_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_MULThow much a spell costs and how hard it lands
travelStep caps on autoexplore / fast-move, the HP divisor below which auto-fight refusesonly if a walk loops, or auto-fight is too brave or too timid
hudMessage-log rows shown at once, the two wrap widths (normal and --MORE--), and how much scrollback LOG_HISTORY_CAP keepsthe log’s footprint on screen
abilitiesThe per-turn odds of the passive abilities (aggravate monster, regeneration, teleportitis, polymorphitis) and MOMENTUM_PER_HIThow often a ring does its thing on its own
conditionsCONFUSION_STUMBLE_CHANCE, the share of a confused player’s steps that go astrayhow much confusion costs (paralysis’s dial is in potions)
speedSLOW_RATE, NORMAL_RATE, QUICK_RATE, FAST_RATE and the ACTION_COST they are read againsthow often each tempo acts
layoutThe grid ordinary floors are laid out on: SECTIONS, GUTTER, PADDING, the smallest room, EMPTY_SECTION_CHOICESthe shape of a floor (moves every layout on every seed)
special_levelsThe carving dials of the labyrinth, vault, bee world, castle and islandthe shape of one special level (moves its layout on every seed)
iceA kicked ice cube’s cold dice and flight range, and how much bone and vapor it shatters intohow hard the cube hits, how showy it ends
shakeHow long each kind of screen shake lasts and how far it throws the maphow 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. See content-tables.md.
  • Animation timing — the millisecond figures in particles.rs (bolt speed, blast ripple) and the wave counts in magicmap.rs. Presentation feel, wound tightly around the code that reads them. The shake table is the exception, in constants::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

TableFileRow type
BESTIARYmodels/src/monsters.rsMonsterDef
POTIONSmodels/src/catalog.rsPotionDef
SCROLLSmodels/src/catalog.rsScrollDef
RUNESmodels/src/catalog.rsRuneDef
WANDSmodels/src/catalog.rsWandDef
WEAPONSmodels/src/catalog.rsWeaponDef
AMMOmodels/src/catalog.rsAmmoDef
LAUNCHERSmodels/src/catalog.rsLauncherDef
ARMORSmodels/src/catalog.rsArmorDef
RINGSmodels/src/catalog.rsRingDef
COINSmodels/src/catalog.rsCoinDef
TRAPSmodels/src/traps.rsTrapDef
DROPSmodels/src/spawn.rsDropCategory
EFFECTSmodels/src/effects.rsEffect
ABILITIESmodels/src/abilities.rsAbility
FLAGSmodels/src/pride.rsPrideFlag

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.

FieldTypeDef.Meaning
name&'static str–Unique; its save identity.
glyphchar–One character on the map.
colorColor–See the palette below.
movementMovementType–Static/Chase/Flee/Confused
hpi32–Hit points; also max_hp.
poweri32–Attack die: 1d[power].
power_bonusi32–Flat, once, on that roll.
armori32–Defence die: 1d[armor].
armor_bonusi32–Flat, once, on that roll.
min_depthu8–Shallowest floor it spawns.
weightu3210Rarity vs eligible pool.
grants&'static [Grant]&[]Effects it is born with.
invisibleboolfalseBorn 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

FieldTypeNotes
effectPotionEffectKeys the mechanic; identity in saves.
name&'static str
colorColor

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

FieldTypeNotes
effectScrollEffectKeys 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

FieldTypeNotes
effectRuneEffectKeys 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

FieldTypeNotes
effectWandEffectKeys the mechanic; identity in saves.
name&'static str
colorColor
rangei32Feeds 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 kindRadiusDamagePayload
attack (is_attack_wand)GRENADE_RADIUSGRENADE_DIE_PER_CHARGE sides per charge, armour-ignoringits element, if it has one
wand of lightGRENADE_RADIUSthe samedazzle on every creature caught
utilityBLAST_RADIUSnonethe wand’s own effect, on every creature caught
wand of nothing—noneconfetti
wand of diggingGRENADE_RADIUSnonea crater: every wall in the disc goes, in sight or not (wands::crater)
wand of swappingBLAST_RADIUSnoneevery 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).

FieldTypeNotes
effectSpellEffectKeys the mechanic; identity in saves (a Spellset is Vec<SpellEffect>).
name&'static strShown on the Z menu and in “You unleash your ___!”
costu8Magic 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.
rangei32Feeds the aiming reticle. Meaningless — left at 0 — for a spell whose SpellEffect::needs_target() is false.
kindSpellKindAttack 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.

FieldTypeDefaultNotes
name&'static str–
colorColor–
power_diei32–Damage rolls 1d[n].
thrown_diei32power_dieDie rolled on impact.
projectileboolfalseBuilt to be thrown.
piercingboolfalseThrow runs the whole line.
returnsboolfalseThe throw ends back with the thrower.
reachi320Aimed rather than walked into: a bardiche (2), a whip (5).
reach_piercingboolfalseThe reach strike runs the whole line.
grants&[Grant]&[]Marker effects the wielder holds while it is in hand.
on_wearOption<OnWear>NoneA one-shot fired the instant it is wielded.
on_doffOption<OnDoff>NoneA 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

FieldTypeNotes
name&'static str
colorColor
diei32Rolled when hurled by hand.
venomboolA hit that draws blood saps the victim’s power (Envenomed). The blowdart.
launched_diei32Rolled instead, once loosed from the launcher that answers to launched_by: an arrow 1d3, a quarrel 1d5, a blowdart 1d2.
launched_byGrantThe 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

FieldTypeNotes
name&'static str
colorColor
grants&'static [Grant]Effects lent to whoever holds it.
melee_capi32Most 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

FieldTypeNotes
name&'static str
colorColor
armor_diei32Defence 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.

FieldTypeDefaultNotes
effectRingEffect–Identity for ident/saves.
name&'static str–
power_diei320Adds to the attack die size, the way power_bonus adds a flat amount.
power_bonusi320.power_bonus(n)
armor_diei320Adds to the defence die size, the way armor_bonus adds a flat amount.
armor_bonusi320.armor_bonus(n)
throw_bonusi320.throw_bonus(n)
grants&'static [Grant]&[].grants(&[...])
on_wearOption<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:

RingRow content
protection.armor_bonus(2)
strength.power_bonus(2), grants SustainsStrength
perceptiongrants SeesInvisible
aggravate monstergrants AggravatesMonsters
sharpshooting.throw_bonus(2)
increase damage.power_bonus(2)
regenerationgrants Regenerates
slow digestiongrants Sluggish
teleportationgrants Teleportitis
polymorphgrants Polymorphitis
stealthgrants Stealthy
maintain armorgrants SustainsArmor
sustain formgrants SustainsForm
dualzapgrants 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”.

FieldTypeNotes
name&'static str
colorColor
effectPickupEffectKeys the mechanic; identity in saves.
amounti32The 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.
weightu32This 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).

CoinEffectWhat it does
goldCoinamount into the score
silverCointhe same, less of it
redHealthheals up to amount, never past max_hp
bluePowerrefills up to amount magic points
roséCleanselifts up to amount afflictions, worst first
greenStrengthgives back up to amount drained power
platinePlatinumthe Plated promise
forgeForgethe Forged promise
hero coinLearnRandomSpellteaches 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.

FieldTypeNotes
name&'static str
colorColor
for_item_usersboolWhether 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.

TreatColourFor
snackdark yellowa creature without hands
fancy of peacecyana 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 HELPER rule set in agents.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

FieldTypeNotes
effectTrapEffectKeys the mechanic; identity in saves.
name&'static strWhat TrapEffect::label() returns.
glyphchar'^' for every row.
colorColorOne per row.
weightu32Rarity relative to its table-mates.
min_depthu8Shallowest floor it can spawn on.
snare_turnsu32Turns 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 tileReachDamageFollow-up
a TrapTRICK_SHOT_RADIUSthe trick-shot dicethe trap’s own effect, per survivor
a Pickup (a coin)PICKUP_TRICK_SHOT_RADIUSthe same dicethe 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 twicethe dice, once per burstthe 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 twicethe dice, once per burstsee 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
FieldTypeNotes
name&'static strThe category, not an item name.
weightu32Share of a floor’s drops.
min_depthu8Shallowest 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:

CategoryWeightShare
scroll30027.0%
potion27024.3%
coin13011.7%
armor807.2%
wand504.5%
ring504.5%
rune504.5%
weapon363.2%
ammo282.5%
launcher161.4%
treat504.5%
deck504.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).

#EffectMeaning
0FireImmuneFire does nothing.
1ColdImmuneCold does nothing.
2UndeadDraining passes through, healing nothing.
3VorpalTargetAny vorpal weapon slays it outright.
4SeesInvisibleSees hidden traps in view, invisible monsters, stashed items.
5SustainsStrengthImmune to dart-trap strength drain.
6AggravatesMonstersPeriodically wakes the floor. Passive.
7ItemUserCatches and wears thrown gear; reads scrolls.
8FireArrowLooses arrows properly (they roll their launched_die).
9FireQuarrelThe crossbow’s half of the same bargain.
10FireDartThe blowgun’s half of the same bargain.
11SustainsArmorWorn armour cannot be corroded.
12RustsArmorEvery blow it lands eats a point of the victim’s armour plus (the aquator).
13SluggishActs one notch below its own tempo. Folded in by conditions::tempo, never written to Speed.
14StealthyUnnoticed until rings::STEALTH_RANGE tiles away.
15RegeneratesMends one condition, or a point of drained power, on a roll. Passive.
16TeleportitisJumps somewhere else on a roll. Passive. Also arms the T key.
17FliesNever springs a floor trap.
18BattyAfter a landed blow, hops to a random open adjacent tile.
19BindsEvery hit clamps the victim in a bear trap’s jaws.
20GorgonPetrifies whoever targets, shoots or zaps it.
21VampiricEvery hit drinks a point of the victim’s maximum HP.
22VenomousIts bite saps the victim’s base power, with no floor.
23ScoreBountyIts corpse pays a multiple of the usual score.
24SplitsCut down short of the last point, it buds a copy of itself.
25GreenBloodWounds well up green. Cosmetic.
26FreezingA chance on every hit to paralyse the victim.
27StealsAndFleesLifts something loose from the victim’s pack, uses it, and vanishes (the leprechaun).
28StealsAndVanishesStrips one equipped item and vanishes with it (the nymph).
29AlwaysTamedAny treat takes, every time (the dog).
30AlwaysHelperCharmed or conjured, it is the Helper, not a plain ally (the dog).
31PriorityHelperA Helper that never explodes to make room, and explodes the ordinary one (the dog).
32ShapeshiftOnKillA melee kill sometimes turns it into another random monster, once (the dog).
33MirrorOnKillA melee kill sometimes turns it into exactly what it killed, once (the mirror hound).
34FaerieOnDeathDying, it is revealed as a faerie shapeshifter and is gone, with no gore (the dog).
35SwimsDeep water is floor.
36PhasingWalks through walls and water; no diagonal rule, no room leash.
37CleavesA connecting swing also lands on every other enemy next to the wielder.
38HeavySwingA hit that lands staggers the victim for a turn; the swing costs the wielder an extra monster round.
39FencerEvery attack is thrown as many times as the grant says (Grant::counted::<Fencer>(n); estoc: ESTOC_NUMBER_OF_ATTACKS).
40LungesClosing the last stride of a run lands a lunge instead of a step.
41LurkThe lurk’s body. An identity effect: revoke_all leaves it.
42WhirlOnMoveStepping between two tiles beside the same enemy lands a free attack.
43VorpalOnConditionA hit on a target with a negative condition slays it outright.
44TurboMagicDamaging spells cost TURBO_MAGIC_COST_MULT times the Magic and deal TURBO_MAGIC_POWER_MULT times the damage.
45SelfDamageOnHitEvery connecting hit costs the wielder a point of HP.
46BuildsMomentumEvery hit builds Momentum on the weapon.
47ShattersStoneLands whole on a Petrified target, past stone_chip.
48ConfusingTouchCharged by a scroll: the next blow it lands confuses the target, then the charge is spent.
49BidedThe spell Bide: the next attack gets BIDE_ATTACK_BONUS, then it is spent or lost.
50AsleepHold: out cold, no action of any kind.
51PetrifiedHold, but not in HOLDS: stone is the body, so it travels with its owner.
52PinnedHold: cannot step, can still strike. Straining costs a turn and blood.
53RootedHold: cannot step, can still strike. Straining costs only the turn.
54ClampedHold: a biter’s grip. Killing the biter frees the victim.
55ConfusedPlayer affliction. A share of moves (CONFUSION_STUMBLE_CHANCE) goes astray. Lifted by a staircase or cancellation.
56BlindPlayer affliction. Sight shrinks to the tile underfoot and no creature is perceptible.
57ParalyzedAffliction: whoever wears it loses a share of their turns.
58MagicWardThe spell: magical hits and a blow’s riders bounce off, for the floor.
59DetectedDrawn on the map where unseen, for the floor. The glyph does not animate or get announced.
60PolymorphedA 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.
61PolymorphitisTurns 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.
62ChimeraOne 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.
63TyphonThe form drawn as T.
64EchidnaThe form drawn as E.
65ProtectedA 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.
66ExplodesOnDeathA rune of justice: dying, it bursts in one fire blast rolled off its own power die (combat::burst_on_death).
67SustainsFormNo polymorph takes hold, and no system shock: the wand, potion, spells and the ring of polymorph all turn aside.
68DualZapAn attack wand zaps twice for two charges (items::plan_use); utility wands, and a wand down to its last charge, zap once.
69SpikedSpikemail: a blow that draws blood from the wearer pricks the attacker for 1d2 straight off their HP (abilities::spike_prick, at Moment::OnStruck).
70ChargesShift + direction with a creature in view (2 to 6 tiles off) charges it. Nihil is born with it.
71VulnMonster 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.

FieldTypeNotes
effectGrantThe marker that arms it — on the attacker for Moment::OnHit, on the bearer for everything else.
whenMomentSee below.
player_onlyboolThe player’s own trick — a monster that steals or catches the weapon behind it still fights the plain way.
actionfn(&mut World, Entity, Option<Entity>) -> boolThe mechanic, run as (bearer, other_end). Reports whether it actually did anything.
flavourOption<&'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:

MomentFiresNotes
OnHit { glancing, lethal }A blow the bearer landedThe 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 probabilityAggravatesMonsters, Regenerates, Teleportitis and Polymorphitis are the rows; see the table for their exact odds.
OnDamagedThe bearer was hurt and livedDriven by helpers::took_damage.
OnTargetedThe player turned their attention on the bearerFires 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).

QualityOddsBonus
NormalNORMAL_QUALITY_PCT+0
ExceptionalEXCEPTIONAL_QUALITY_PCTEXCEPTIONAL_BONUS_MIN .. EXCEPTIONAL_BONUS_MAX
Cursedwhatever is leftCURSED_BONUS_MIN .. CURSED_BONUS_MAX, plus a Curse tag
MEGACURSEDMEGACURSED_PCT_OF_CURSED% of the cursedMEGACURSED_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.

QualityNumeric ringAny other ring
NormalPLAIN_BONUS (+2, which is the row)nothing
ExceptionalEXCEPTIONAL_BONUS (+3)nothing
CursedCURSED_BONUS_MIN .. CURSED_BONUS_MAX (-3 .. +2), plus a Curse taga Curse tag
MEGACURSEDrings::MEGACURSED_BONUS_MIN .. MEGACURSED_BONUS_MAX (+0 .. +13), plus Curse and MegaCurse tagsCurse 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.

ConstantDefined in
MAP_WIDTH / MAP_HEIGHTconstants::map
FINAL_DEPTH — the floor holding the relicconstants::progression
DUNGEON_LORD_PATIENCE — turns on one floor before evictionconstants::progression
STACK_LIMIT — most of one item per pack slotconstants::items
PACK_CAPACITY — most slots a pack holds at onceconstants::items
THROW_RANGE — how far you can hurl a heavy thingconstants::items
LIGHT_THROW_RANGE — the same, for a potion, scroll, wand or ringconstants::items
LAUNCHER_RANGE — how far a bow or crossbow carries its own ammoconstants::items
Speed::COST — energy one action costscomponents.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:

  1. Advance the game. A fast-move run in progress (FastMove::active) resolves entirely inside fast_move_run — it steps the schedule itself, turn by turn, without repainting. Otherwise, one player step is taken (see player_step below); if it consumed a turn, schedule.run fires once.
  2. Play any queued animation. view::play_particles then view::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, in rendering.md): a prompt up during an animation asks for the very keypress that skips it.
  3. Render. view::render.
  4. 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 -nshake means none ever is); see rendering.md, “The screen shake”.
  5. 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.
  6. Check the ending. Ending::player_dead or player_won breaks 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)ResourceHandler
Key list (F1)HelpMenu.opendispatch_key itself: any key closes it, nothing else runs
Item Info popupItemInfo.opendispatch_key itself: any key closes it, nothing else runs
“Really quit?”QuitPrompt.openanswer_quit_prompt
Aiming (a throw or a ranged use)TargetingState.activehandle_targeting_input
Pack openPackIsOpen.openhandle_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:

  1. Paralysis — not checked here at all: a potion of paralysis eats its turns upstream, in process_input_and_update, and drops the player’s Speed to Slow for the ones it leaves. By the time a step reaches queue_step the turn is the player’s to spend.
  2. Confusion stumble (maybe_stumble) — while Confused, a coin flip hijacks the step into one of the eight STUMBLE_DIRS at 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-checks Lunges (lent to the wielder while an estoc is in hand) and the geometry: the near tile (new_x, new_y) open and unoccupied, a Mob on 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, tagging EntityMoved — a full alternate ending to the function, never falling through to steps 2+. A no-op, returning false, for anyone not wielding one.
  3. Wall. Map::blocks.
  4. 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.
  5. A Mob on the target tile — attacks instead of moving (models::melee_attack, which resolves the plain opposed-roll swing via resolve_attack plus 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). An IceCube is the other exception: the player kicks it (models::kick_ice_cube) — it homes on the auto_fight_target for cold damage, or flies off the way it was kicked, and shatters either way, spending the turn. The player’s Helper is 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-up Momentum is done the moment its wielder does anything but keep swinging it.
  6. 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 it bear_trap_thrash: a wasted turn, a scratch of damage, blood. A scroll’s hold costs the turn and nothing else. See docs/*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.)
  7. The move. Position updates, the player’s Viewshed is marked dirty, and EntityMoved is tagged on the player so trap_system checks the new tile. A Helper the player walked into takes the tile they left, tagged EntityMoved too. 6.5. A chain-sickle’s whirl (models::try_whirl_attack) — self-checks WhirlOnMove and looks for a monster adjacent to both the tile just left and the tile just reached (a step taken alongside an enemy), landing a free melee_attack on it if one qualifies.
  8. Pickup. An Item on 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.”). A Hidden (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.

Keyhandle_movement_input does
i a t d e q r z w W Popen_pack(world, PackMode::…) — see below
o / Oauto-explore / travel cursor
Atoggle_auto_pickup — flips AutoPickup::enabled, logs which way it landed, spends no turn
f / Tabfire the wielded launcher / auto-fight; with a Returns weapon in hand (models::wielded_returner), both throw it instead (aim_returner / ranged_auto_fight)
vbegin_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)
Tundocumented 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
Zbegin_spells_menu — the spells list, rows lettered a-d. The slower way to an active spell.
Alt+Q W E Rfire_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
F1open HelpMenu, the key list. No turn
F2toggle CommandBar.hidden, the yellow bar. No turn
xmodels::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
Escraise 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+Cnever 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 of handle_movement_input, owns H J K L Y U B N outright. A command on any of those eight is dead code — this is what happened to L for look.
  • the x/X escape hatch in dispatch_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+q w e r, in handle_movement_input before 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:

ItemActionWhat happensSpends a turn?
Useuse_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
Throwaim_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
Dropqueue_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
InfoFills 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):

LoopArmed byResourcePlannerStops on
Fast move (“run”)Shift+directionFastMovefast_move_plan, then travel_step/straight_step each stepkeypress, a monster in view, a message logged, junction/target reached, FAST_MOVE_STEP_CAP
Auto-exploreoAutoExplore (target: None)explore_stepsame, plus “nowhere left to explore”
Travel>/< to a known but distant staircase, or the O cursorAutoExplore (target: Some(tile))travel_stepsame, plus arrival
Travel cursorOTravelCursor(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 mashing x cannot lose the line telling them why they are about to die.
  • The x / X escape hatch. Either closes whichever modal is open. A bare x on 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; Esc closes 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):

  1. Top HUD (row 0) — the dungeon’s half of the status. See below.
  2. Terrain — every tile with visible or revealed set; revealed-but-not-visible tiles are painted DarkGrey (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.
  3. Blood overlay — recolours a tile’s existing glyph (set_fg, not put) DarkRed, only where currently visible and never where occupied_by_actor — the stain marks the floor, not whatever is standing on it. Skipped entirely while the player is Blind: blood is carried by colour alone, and they have none.
  4. Corpses — same visibility rule as blood, drawn as a %.
  5. Floor items — visible and not actor-occupied.
  6. Traps — the one exception to “unseen means blank”: a previously-discovered trap (Without<Hidden>) still shows in DarkGrey once out of sight, same as a known staircase.
  7. Smoke — visible, not actor-occupied, ≈ in grey; fades on its own over a few turns (see Smoke in components.md).
  8. Actors — the player and every non-Hidden Mob, visible only. (While the player is Blind the visibility system has already tagged every mob Hidden, so nothing here has to know about blindness.)
  9. Monster status tints — a background (set_bg) on a visible, non-Hidden mob that cannot fight back properly, in this precedence (status_tint): DarkBlue for one Asleep, Cyan for one Paralyzed, DarkGreen for one held down (Pinned, Rooted or Clamped), Yellow for one staggering (MovementType::Confused or Flee), Grey for one merely slowed. The glyph repaints black over the tint. The player’s Helper takes DarkCyan instead, 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 bright Magenta background on any visible, non-Hidden mob 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 — see content-tables.md, “Trick shots”). A trap still Hidden is 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.
  10. 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 flat DarkMagenta. Anything in view is already drawn above in its own colour; this layer is the sense, not the sight.
  11. Particles — drawn over actors deliberately, so a hit motes over the thing it hit rather than under it.
  12. 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 a DarkBlue background. After the line, the blast preview: every in-view tile of models::aim_footprint gets a DarkYellow background, the tip aside. A spell or a zap bursts on the cursor; a throw bursts where flight_path says it comes down, traced past Hidden creatures so the preview gives none away, and a wand lobbed onto open floor previews nothing because it lands whole.
  13. Travel cursor — a background-only highlight (set_bg), so the glyph and colour of whatever’s on that tile stay readable.
  14. 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 via CommandBar) and the message log (rows 23–24, from column LOG_X, or column 0 once the bar is hidden). The log’s --MORE-- prompt waits for the effect layer — see log_panel, under “The two blocking loops” below.
  15. Inventory overlay — drawn last, on top of everything. The F1 key list (draw_help, rows from strings::help_rows), the Info popup (draw_item_info, its ItemInfo lines 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 match over hand-computed percentage ranges in map.rs (now map/population.rs). Adding a category meant recomputing every boundary.
  • Monster depth gating was a tier field 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, a const ALL array, 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:

  1. One spawn entry point keyed by name. spawn_named(world, name, pos) resolves any name the game knows – monster, item, trap, relic – and returns None on a miss. This is spawn_named_entity from the chapter.

  2. Spawn tables as data, decoupled from definitions. DROPS is a weighted, depth-gated list of categories. MonsterDef and TrapDef carry weight and min_depth on the row. pick_weighted is the one draw function all three share.

  3. A registry that reads the tables. content_names() enumerates everything, which backs the -content listing and the table tests.

Rejected:

  1. External JSON/RON raw files parsed at run time. Content stays in const tables 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() and spawn_named gave -content, NIHILURK_SPAWN and 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.

  1. Every crate that ships is nihilurk or nihilurk-<thing>. engine/tests/workspace.rs fails if a new one is not.
  2. The binary’s package takes the bare name nihilurk, which is free. The binary was already called nihilurk, so cargo install nihilurk installs the thing it says.
  3. 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.
  1. compat/ is already nihilurk-compat and is publish = 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:

  1. 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_models and broken every use 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.
  2. One crate. Fold models, strings and particle-core into one nihilurk crate as modules and publish once. Two reasons against: particle-core is no_std with 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 beside bevy_ecs and the save-file code, and that would end. And models is the crate the documentation is written around: almost every page under tutorial/, how-to/ and reference/ 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 -p takes the package name, so it is cargo test -p nihilurk-models and cargo run -p nihilurk, while the code says models. This ADR is the page that explains it.

Benefits realised:

  • The game, its tests and its docs kept every use models:: line.
  • cargo install nihilurk installs the game.
  • particle-core stays small, no_std and 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 models or engine ever 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.

  1. cross_build.sh cross-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.
  2. The two things the build cannot prove are the shell and the start. run_check.sh checks exactly those, with nihilurk -content: a headless print-and-exit that never touches the terminal.
  3. No CPU cap, no memory cap, no pty sizing, no --exec-emulated.

Rejected:

  1. 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:

  1. The --MORE-- gate. log_panel holds the prompt back until the effect layer is empty, because play_particles reads 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 in view.rs pin it, on a fixture of two resources.
  2. particle-core. It is no_std, and f32::ceil has two implementations. If they disagree, a no_std build animates differently from the game. tests/parity.rs runs both under cargo test.
  3. models/tests/it/determinism.rs. It proves cosmetic randomness comes off FxRng and never perturbs GameRng.

Consequences

Accepted costs:

  • The draw order in render is 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.

  1. 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.
  2. The two orderings are 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.
  3. The schedule is built by turn_schedule() in models/src/schedule.rs rather than inline in main, 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-ai world. 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.

  1. Rows may be reordered and retired freely. The one rule is: never rename an id. A bestiary row lives by the same rule.
  2. 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.
  3. 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 EFFECTS attaches 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.

  1. Out of the player’s view, a monster carrying Aggravated takes 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.
  2. MovementType::Aggravated { tx, ty } stays in the enum, retired, because a save writes the enum by position. A save that carries it loads as Chase plus 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.

  1. models::plan_step reads the world and returns a StepPlan. It changes nothing except the confusion roll, which has to happen at the key so the random draws stay in their old order.
  2. models::queue_step plans, queues a PlayerAction::Step unless 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_charge and queue_reach_attack check what would refuse, log it and return false, or queue the action.
  3. player_action_system is the schedule’s first step. It drains PlayerActionQueue and applies each plan. Everything else in the turn runs after it, so the player still acts before ai.

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_step sees the old position. Callers run the schedule first, and tests call player_action_system.
  • Two places describe one decision tree. plan_step and apply_step both name the same variants.

Benefits realised:

  • A bot can drive the real turn by queueing actions, which models/tests/it/soak.rs does.
  • EntityMoved is set and cleared inside models.
  • 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:

  1. 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.
  2. Read the constant. DAMAGE_DICE * DAMAGE_SIDES instead of 8; share_of("coin") off DROPS instead of 17.0. Now the test proves the roller honours the table rather than proving the table says what it says.
  3. Purge it. If a test’s only claim was the number, delete it and put something real in its place. “A stashed item carries Invisible and Hidden” 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 (BESTIARY and 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) roll EXCELLENT_HIT_DICE dice 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 within CURSED_SPLINTER_RADIUS.

  • Chip damage. The player’s worst swing still takes CHIP_DAMAGE off – 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 in models/src/components.rs. Anything that is an effect — a property a creature holds, from FireArrow to Blind — goes in models/src/effects.rs instead, where it gets a registry row, an id in the save file and a lifetime. That file is nouns: every Component, Resource and Event the 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 Def row (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’s snare_turns) live here too, so adding a row that wants its own value is still a one-file change – not a trip to constants.rs.

  • The Bundle – the struct that assembles the components for one spawn (MonsterBundle, TrapBundle) – lives next to its table, not in components.rs. It is glue between a Def and 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_rng re-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:

DirectoryThird keyWhat it promises
reference/StatusIt tracks the source, and the source wins.
explanation/This isIt 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”:

Projectile means 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..=9 is not testing the game; it is testing constants.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.md does 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 -- -content reads 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 T key and the -pride flags stay out of MANUAL.md and out of ../reference/cli-and-env.md on 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 models without 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.rs exists to hold up the claims how-to/ makes about the tables.
  • Qualify a path it cannot keep. `models/src/map/levels.rs` is a claim about where something lives, and docs_style.sh checks 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 asks world.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 a match in items/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, PackIsOpen and MagicMapReveal are 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, ScoreFlash and FxRng are reached through get_resource_mut, never resource_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 every world.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_system walks Or<(With<Player>, With<Mob>)>, not every entity in the world, because an effect never lands on an item — a ring carries Grants, 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/Ring fields 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_CHARGE sides 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 to MovementType::Confused, the player gains the Confused condition. “dazzle” in the log.
  • Utility wands (polymorph, haste, slow, teleport away/to, cancellation) throw a small BLAST_RADIUS blast 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, including tests/parity.rs. That crate is no_std, and f32::ceil has two implementations — the hosted intrinsic and a hand-rolled branch. If those disagree, a no_std build animates differently from the game. parity.rs runs both branches under cargo 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 off FxRng and never perturbs GameRng.

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-heldtutorial/add-your-first-item.md
Add my first monster, hand-heldtutorial/add-your-first-monster.md
Add my first spell, hand-heldtutorial/add-your-first-spell.md
Add my first body, hand-heldtutorial/add-your-first-body.md
Give a monster a mind, hand-heldtutorial/give-a-monster-a-mind.md
Add a monsterhow-to/add-a-monster.md
Add a new playable body, like the lurkhow-to/add-a-body.md
Add a potion, wand, weapon, ringhow-to/add-an-item.md
Add an active spellhow-to/add-a-spell.md
Add a traphow-to/add-a-trap.md
Add a property like “fire immune”how-to/add-an-effect.md
Add a way for a creature to thinkhow-to/add-a-rule-set.md
Add one reflex to that thinkinghow-to/add-a-rule.md
Make something rarer, or deeperhow-to/tune-rarity-and-depth.md
Add a whole new kind of itemhow-to/add-an-item-category.md
Put a specific thing on a specific tilehow-to/spawn-a-thing.md
See a change working in the real gamehow-to/play-through-a-pty.md
Look up a field, a type, a defaultreference/content-tables.md
Look up a component, resource, eventreference/components.md
Change a balance numberreference/constants.md
Look up a function I have to callreference/spawn-api.md
Look up a flag or an env varreference/cli-and-env.md
Look up a conduct, or what a win is paidreference/conducts.md
Look up how input and the turn loop workreference/input-and-turn-loop.md
Look up what a monster does with its turnreference/agents.md
Look up how a frame gets to the screenreference/rendering.md
Reach an entity and change ithow-to/work-with-the-ecs.md
Understand why it is built this wayexplanation/data-driven-content.md
Understand how bevy_ecs is used hereexplanation/ecs-in-nihilurk.md
Understand why monsters think in rule setsexplanation/agents.md
Know what an effect should look likeexplanation/the-feel-layer.md
Write or edit a page in hereexplanation/documentation-style.md
Know which pages to touch after a changehow-to/update-the-docs.md
Know what good numbers look likeexplanation/combat-and-balance.md
Know what shape to leave the code inexplanation/code-calisthenics.md
Check it still runs on a Pihow-to/run-the-compat-pipeline.md
Know how portability is testedexplanation/cross-platform-testing.md
Cut a release, one command and a pushhow-to/cut-a-release.md
Publish a release to crates.iohow-to/publish-to-crates-io.md
Publish a GitHub releasehow-to/publish-a-github-release.md
Publish the documentation sitehow-to/publish-the-site.md
Know why package names differ from directoriesexplanation/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:

  1. The docs describe tables, and the tables are short. There is rarely more than one page to touch.

  2. models/tests/it/content.rs enforces the claims this documentation makes about the tables – unique names, reachable rows, depth gating, no catalog row spawning a dud – and models/tests/it/determinism.rs enforces the one claim that protects everyone else’s saved seeds: adding content cannot move a wall. engine/tests/workspace.rs is the third of that kind: it checks that a bare cargo test still covers every crate that ships. If a doc claim can be a test, make it a test and cite it here.

  3. cargo run -p nihilurk -- -content prints 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?