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

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