How to add a spell
Audience Content author.
Prerequisites You know where `models/src/catalog.rs` is. If not,
do `../tutorial/add-your-first-spell.md` first.
Result A new active ability the player can trigger from
the `Z` menu, that costs Magic -- an
aimed **attack** like a wand, or a self/room-wide
**skill** like a potion or a scroll.
One enum variant, one catalog row, one arm of an exhaustive match. The same shape as a potion, a scroll or a wand – with one difference worth saying up front, and a fork partway through depending on which of the two you’re building.
A spell is not an item
Every other thing you can pick up in nihilurk is an entity: it has a Position on the floor, an Item marker, a pack slot, and a fate when you drop or throw it. A spell has none of that. It is never spawned, never lies on a floor, and cannot be dropped or thrown – there is nothing to drop.
A spell lives as a bare SpellEffect value inside a creature’s Spellset component:
pub struct Spellset {
pub slots: Vec<SpellEffect>,
}
Today only the player has one, taught in play by a hero coin (an uncommon coin-table row that puts a random, not-yet-known spell straight into the taker’s Spellset, up to constants::spells::SPELLSET_CAP – four) rather than started with any. Giving a spell to the player for testing without going through the drop table means putting it in that Vec by hand – there is no NIHILURK_SPAWN for this, because there is no entity to spawn. See “Hold it in your hands” below.
The other consequence of not being an item: a spell costs Magic, not a battery. SpellDef::cost is spent by spell_system every time the player triggers it. A monster that does the same trick under its own steam – the dragon’s fireball, the eel’s thunderbolt – has a Spellset and a Magic pool of its own, put there by its bestiary row (MonsterDef::casts). It pays SpellDef::cost out of that pool through the same spell_cost the player does (pay_for_spell), and it never refills.
The recipe
Two steps are the same whatever you’re building; then the path forks on SpellKind.
-
Append a variant to
SpellEffectinmodels/src/components.rs– at the end, never in the middle (see the warning below):pub enum SpellEffect { DragonBreath, // ... more variants ... GateDown, IceBolt, } -
Add the row in
SPELLS(models/src/catalog.rs):SpellDef { effect: SpellEffect::IceBolt, name: "Ice Bolt", cost: 2, range: 6, kind: SpellKind::Attack },costis Magic points, multiplied byconstants::spells::TURBO_MAGIC_COST_MULTwhen a staff (TurboMagic) is wielded andkindisSpellKind::Attack, never for aSpellKind::Skill.costis also calibrated againstconstants::player::START_MAGICand sits in one of four Magic-cost tiers, 1 through 4; the rows already inSPELLSshow where each tier lands.rangefeeds the aiming reticle exactly like a wand’sRanged, and is meaningless – leave it0– for a spell you’re about to make a Skill.List the spell in
every_spell_has_a_catalog_row(models/tests/it/creature_casts.rs), in itsmatchand in itsallarray.cargo testwill not build until you do.
Which of the two you’re building decides everything from here.
Create an attack
An Attack opens the aiming reticle, deals damage (or a status a target can shrug off, like Thunderbolt’s paralyse), and is the one TurboMagic touches at all: a staff multiplies its cost by constants::spells::TURBO_MAGIC_COST_MULT and its damage by the larger TURBO_MAGIC_POWER_MULT, which is what makes the staff worth the Magic.
-
Write the mechanic as one arm of
apply_spell_effect(models/src/items/spells.rs):fn apply_spell_effect(world: &mut World, user: Entity, target: Position, effect: SpellEffect, power_mult: i32) { match effect { SpellEffect::DragonBreath => breathe_fire(world, user, target, power_mult), SpellEffect::IceBolt => ice_bolt(world, user, target, power_mult), } }The match has no catch-all, the same as
apply_wand_effectoverWandEffect– step 1’s new variant will not build until this step gives it an arm. The compiler names the function and the missing variant, so there is nothing to remember.power_multisconstants::spells::TURBO_MAGIC_POWER_MULTwhen a staff multiplied the cost,1otherwise – fold it into your damage roll (multiply the final number, the way every existingAttackarm does; seesting,thunderbolt,force_lancefor the shape when armour is or isn’t subtracted).Most of an attack’s mechanic is borrowed rather than written:
- A blast at a point –
breathe_fire,lux,meteor_strikeall callelemental_blast(items/wands.rs), the same function a zapped or thrown wand calls. Pick a radius (BLAST_RADIUSfor a zap-sized disc,GRENADE_RADIUSfor a thrown-sized one), a damage roll, and anElement(orNonefor non-elemental). - A line –
force_lancetracesget_line(user_pos, target)by hand, stopping at the first wall, and rolls damage per creature it crosses. Copy it when the flavour is “a bolt” or “a beam” rather than “an explosion”. - A single target hit like a trap or a wand bolt –
sting(the dart trap’s own formula, aimed rather than laid) andthunderbolt(a plain armour-ignoring roll, like a wand bolt) both just callcrate::helpers::monster_at(world, target)and roll once. - Everything in view, no target needed –
circle_of_deathandfrost_novacallcrate::helpers::hostiles_in_view(world, user)instead of aiming at a point; pair this shape with step 4’s “no target” answer below.
Whichever shape you pick, land the damage through
helpers::apply_hitwith aHit::magicorHit::elementalrather than writing to aFighter’shpyourself – that one call is what makes Magic Ward, elemental immunity and a petrified victim’s damage cap apply to your spell. See “Play fair with Magic Ward” below. - A blast at a point –
-
Say whether it needs a target. Every attack above that hits one aimed tile does;
SpellEffect::needs_target(models/src/components.rs) lists the ones that don’t, because they hit everyone in view instead (Circle of Death, Frost Nova) – add your variant there if that’s the shape you picked in step 3, the same wayWandEffect::needs_targetcarves the wand of light out from every other wand. Leave it out (needing a target is the default) for anything aimed.
Create a skill
A Skill never opens on damage. It changes the caster (Cure, Heal, Bide, Magic Ward, Haste Self), the floor around them (Setup), or borrows a scroll’s own effect outright (Identify, Magic Mapping) – and TurboMagic never touches its cost.
-
Decide if it needs a target at all. Almost none do – a skill works on the caster or on everyone in view, the same as a room-wide attack. Add your variant to
SpellEffect::needs_target’sfalselist (models/src/components.rs) unless you are building the rare aimed skill (Polymorph Other is the one today:kind: Skillwith arange, so a staff does not double its cost; treattargetas step 3 of “Create an attack” would). -
Write the mechanic, the same match arm as an attack, but built one of two ways:
-
Borrow a scroll wholesale. Identify and Magic Mapping are one line each:
SpellEffect::Identify => super::scrolls::apply_scroll_effect(world, user, ScrollEffect::Identify),Reach for this whenever an existing scroll already does exactly what you want – a spell is a delivery method, not a reason to write the mechanic twice.
-
Write it fresh, reaching into
crate::conditionsfor anything that changes the caster’s state (cure_one_condition,hasten,paralyse, …) the waycure_selfandhaste_selfdo, or lending a marker component througheffects::lendthe waymagic_wardlendsMagicWardforLifetime::FloorandbidelendsBidedforLifetime::NextAction. A skill that plants something on the floor (Setup) reaches intocrate::trapsinstead – seesetupfor spawning a revealed trap and springing it immediately if something is already standing there.
power_multis still a parameter every arm takes, by convention (every arm in the match has the same signature) – aSkillis free to ignore it, sincespell_systemnever doubles aSkill’s cost. -
Play fair with Magic Ward
Any damage your spell deals to another creature goes through helpers::apply_hit, the same way every existing damaging spell does:
if apply_hit(world, victim, Hit::magic(damage), Some(&line)) == 0 {
return; // or `continue`, in a loop over several victims
}
Hit::magic (no element) and Hit::elemental (fire, cold, …) both set the hit’s magical flag, and apply_hit reads it: a warded victim takes nothing, the log says so, and wands::ward_ricochet plays the bounce. Hit::physical is the opt-out – steel, wood and gravity, which a ward is no help against.
No spell checks MagicWard by hand, and none should. apply_hit owns that question along with elemental immunity and the stone-chip cap, so routing through wands::elemental_blast gets all three for free – the blast calls apply_hit per victim itself. The only other ward check in the game is abilities::fire_on_hit, and it answers a different question: whether the trick riding on a landed blow takes hold. Roll damage straight onto a Fighter and your spell becomes the one hole in an otherwise complete shield, which is worse than it sounds: the player bought that immunity with three whole Magic points.
Hold it in your hands
There is no NIHILURK_SPAWN for a spell. In play the only way to learn one is a hero coin (see “A spell is not an item” above); to try Ice Bolt without hunting for a coin, replace the player’s starting Spellset::default(), in initialize_world (models/src/map/levels.rs), with:
Spellset {
slots: vec![SpellEffect::IceBolt],
},
Build, run, and press Z to see it listed, then its row letter to aim it, or Alt+Q/W/E/R for slots one to four. Tab while aiming snaps the reticle to the next thing in view.
If this was a dry run, git checkout models/src/map/levels.rs along with catalog.rs, components.rs and items/spells.rs.
Append enum variants; never insert or reorder them.
SpellEffectderivesSerialize, and a save encodes a variant as its position in the enum – including inside a savedSpellset(see the next warning). Adding one at the end is safe; putting one in the middle silently turns a saved spell into a different one, for every save that had it.
Spellsetdoes survive a save –models/src/saveload.rsround-trips it like every other piece of the player. So do the two marker components spells have left behind so far,MagicWardandBided– but not through a field of their own: each is a row in theEFFECTSregistry (models/src/effects.rs,"magic_ward" => MagicWard;) lent througheffects::lend, andEntitySave::effectssaves it by id with theLifetimeit is being held for. If your new spell leaves a marker of its own lying around outsideSpellset, register it there and lend it; a component inserted directly is the one a reload quietly forgets.
A monster casts from its spellset, out of its own Ma. Its rule set picks one of the spells in its
Spellsetthat itsMagiccan pay for, at random each turn, and fires it if it can (../reference/agents.md, thecastrule). To give a species your spell, chain.casts(n, &[SpellEffect::YourSpell])on its bestiary row – that is all the dragon’s Fireball is. Only anAttackspell with a range is ever fired this way; a skill never is. A player wearing the row (-am) gets the spells in the spell bar and pays from the player’s Ma.
Verify what you added
cargo build
cargo test --test it spells::
There is no cargo test --test it content:: coverage for spells the way there is for monsters and items – SPELLS is not part of spawn_named or content_names(), because nothing about a spell is spawned. models/tests/it/spells.rs pins a spell’s mechanic instead: it pushes a WantsToCast onto SpellQueue and runs spell_system against a dummy. Copy sting_bites_like_the_dart_trap_it_borrows_from if your spell has a number worth holding still.
Then prove it by hand: give it to the player, aim it, and watch the log line spell_system prints ("You cast ___!") followed by whatever apply_spell_effect logs itself.
Appendix: quick check
- Append a variant at the end of
SpellEffectinmodels/src/components.rs. - Add the
SpellDefrow toSPELLSinmodels/src/catalog.rs:cost,range(0for a skill),kind. List it inevery_spell_has_a_catalog_row(models/tests/it/creature_casts.rs). - Attack: write the arm in
apply_spell_effect(models/src/items/spells.rs), and land damage throughapply_hitwithHit::magicorHit::elemental. - Attack that hits everyone in view: add it to
SpellEffect::needs_target. - Skill: add it to the
needs_targetlist unless it is aimed, then write the arm, borrowing a scroll if one already does the job. - A marker the spell leaves behind: add it to
EFFECTSand lend it througheffects::lend. - Give it to a species with
.casts(n, &[SpellEffect::<Name>]); only a ranged attack is ever cast that way. - Try it: put it in the starting
Spellsetininitialize_world, pressZ, then revert that edit. - Run
cargo test --test it spells::. - Fix the docs: nothing, unless it adds a
constants::module (update-the-docs.md).
See also
../tutorial/add-your-first-spell.md the long version, as a lesson
add-an-item.md the pattern this one borrows
../reference/input-and-turn-loop.md where spell_system sits in the schedule