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

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