How to spawn a thing
Audience Engine developer, or a content author who wants to
look at the row they just wrote.
Prerequisites Rust and a passing familiarity with bevy_ecs.
`../reference/spawn-api.md` lists the signatures;
this page is the recipes.
Result A named monster, item, trap or the relic, in the
world, on the tile you meant.
One function covers nearly every case:
spawn_named(&mut world, "dragon", Position { x: 10, y: 4 })
It searches every table by name and returns Option<Entity>. Four recipes follow – looking at something now, one in a test, one from game code mid-run, and loot the way the dungeon rolls it. Take the one you are standing in.
Recipe 1: look at it right now
No code. NIHILURK_SPAWN drops names on free tiles around the player on every floor it builds:
cargo run -p nihilurk -- -content # 1. what are the names?
cargo run -p nihilurk -- -content | grep -i wand
NIHILURK_SPAWN="dragon" cargo run -p nihilurk # 2. put one in front of me
NIHILURK_SPAWN="bow,arrow,arrow,dart trap" cargo run -p nihilurk
Gear and ammunition take dressing: cursed (or megacursed), +N/-N and xN.
To press keys in the game it starts and read the screen back as text, with no terminal of your own, use play-through-a-pty.md.
NIHILURK_SPAWN="cursed -2 long sword,+3 ring mail,arrow x13" cargo run -p nihilurk
Names are comma-separated and trimmed; a name the tables do not know is skipped silently, so check it against -content rather than trusting an empty floor. Full rules: ../reference/cli-and-env.md.
Recipe 2: one specific thing in a test
-
Build a world. A bare one is enough to spawn into and assert about, and needs no resources at all:
use bevy_ecs::prelude::*; use models::*; let mut w = World::new(); let dragon = spawn_named(&mut w, "dragon", Position { x: 5, y: 5 }).unwrap();That is what
models/tests/it/content.rsdoes for all hundred-odd rows. Use it whenever the thing itself is the subject – its components, its name, its glyph. -
Or build a real floor, when the thing has to be somewhere – a monster that walks, an item auto-explore has to find, anything that reads
Map:fn test_world(seed: u64) -> World { let mut w = World::new(); w.insert_resource(GameRng(ChaCha12Rng::seed_from_u64(seed))); w.insert_resource(RngSeed(seed)); w.init_resource::<GameLog>(); w.insert_resource(PlayerName { what: "TESTER".into() }); initialize_world(&mut w); // map, player, starting kit, floor 1 w }Copy it from
models/tests/it/identify.rs, and add the queue your system reads (w.init_resource::<UseQueue>(),ThrowQueue,AttackQueue) if you are about to run one. The player isw.query_filtered::<Entity, With<Player>>().single(&w). -
Put it where the test needs it. Three places a thing can be, and the second and third are alternatives:
// on the floor -- it already is, from the Position you passed let pos = Position { x: 5, y: 5 }; let sword = spawn_named(&mut w, "long sword", pos).unwrap(); // in the pack -- strips Position, merges ammo, respects PACK_CAPACITY stow(&mut w, player, sword); // or worn/wielded -- no log line, no turn, no pack slot needed assert!(equip_silently(&mut w, player, sword));initialize_worldhands the player a starting kit – ring mail, a mace, a short bow – and that kit is already on.Slot::BodyandSlot::Handhold one thing each, soequip_silentlyreturnsfalsefor a second suit of armour until you take the first one off:if let Some(worn) = equipped_in(&w, player, Slot::Body) { force_unequip(&mut w, worn); }If the kit competes with what you are testing in the pack rather than on the body, empty the pack first –
empty_packinmodels/tests/it/identify.rsis the pattern. -
Name it in a literal and let it panic.
MonsterDef::named("dragon")andTrapDef::of(TrapEffect::Bear)panic on a miss, which is what you want from a typo in a test. Savelookupfor names that came from outside the source.
Recipe 3: one from game code, mid-run
The scroll of create monster (models/src/items/scrolls.rs, create_monster) is the worked example. Four steps, in this order:
-
Find a free tile. Never assume one:
let spot = free_adjacent_tile(world, origin) // next to someone .or_else(|| random_open_tile(world)); // anywhere on the floor let Some((x, y)) = spot else { world.resource_mut::<GameLog>().add("...nothing happens."); return; };Both read the
Mapand both can fail. The refusal branch is not optional – a sealed-in player is a legal floor.These two are inside
modelsonly (crate::helpersandcrate::traps, neither re-exported). From a test or fromengine/, either pick the tile yourself againstMap::blocksor callspawn_list, which does the searching for you. -
Roll from
GameRng, in a scoped block. The live run rolls fromGameRngand never fromlayout_rng/content_rng– those two belong to floor generation, and borrowing one while spawning will not compile anyway:let idx = { let mut rng = world.resource_mut::<GameRng>(); rng.0.gen_range(0..BESTIARY.len()) }; // <- borrow ends here, before the spawn -
Spawn through the right door:
let pos = Position { x, y }; spawn_monster(world, &BESTIARY[idx], pos); // monsters spawn_named(world, "long sword", pos); // items, by name world.spawn(TrapBundle::from_def( // a trap is a bundle, TrapDef::of(TrapEffect::Bear), // not an entity TrapReveal::Sight, pos, ));spawn_monsteris the only way a monster should enter the world: it attaches the row’s granted effects and itsInvisiblemarker.TrapBundle::randomis the rolled version, but it wants aChaCha12Rngof its own – floor generation hands it acontent_rng. From a live system, name the trap you mean. -
Say so in the log. Use
item_label(world, entity)(helpers, models-internal again) for the name the player is allowed to know – an unidentified suit of armour is “leather armor”, not “+2 leather armor” – andarticle_for(&name)for the “a”/“an” in front of it.
Recipe 4: loot the way the dungeon rolls it
spawn_named gives you the row. roll_item gives you a drop:
let item = roll_item(world, &mut rng, depth, pos);
Weighted category, weighted row within it, then spawn_as_loot – so it arrives enchanted, charged and bundled, gated to the depth you passed. This is the only place the dungeon decides what loot exists; call it rather than re-rolling quality yourself.
The rng is the caller’s, not GameRng: floor generation passes its own content_rng(seed, depth, changes), which is what keeps a floor’s contents keyed to the seed and the staircase count rather than to the fighting. A test can pass any ChaCha12Rng::seed_from_u64.
To roll one piece of quality onto something you spawned by name:
let sword = spawn_named(world, "long sword", pos).unwrap();
enchant_equipment(world, &mut rng, sword); // +1..+3, or cursed
What comes out is the bare row
spawn_named and the spawn_* wrappers build the thing exactly as its table row describes it – no dice are rolled. What that means per kind:
| You spawn | You get | Not |
|---|---|---|
| a wand | Battery { charges: WAND_CHARGES }, full, same as a drop | a battery rolled off constants::wands |
| a weapon, armour, a ring | no bonus, no Curse | an enchantment roll |
| arrows, quarrels | Stack { count: 1 } | a bundle |
| a trap | TrapReveal::Sight, so you can see what you placed | a rolled reveal style |
| a monster | the row, effects and invisibility included | anything depth-scaled |
That is deliberate: a test that spawns a thing needs it to be the same thing every time. When you want the randomised version, use Recipe 4.
When it does not work
spawn_named returned None. The lookup is a case-sensitive exact match on the row’s name – "Dragon" and "long Sword" find nothing. Check cargo run -p nihilurk -- -content.
It spawned but nothing is drawn. Either it has no Position (being in a Backpack means exactly that: stow removes it, and the renderer draws the pack separately), or the tile is not currently visible – floor items are only drawn on revealed tiles, and a monster gets Hidden until the player’s viewshed reaches it.
It landed in a wall. Nothing in the spawn API validates the tile you hand it; Position is whatever you said. Use free_adjacent_tile, random_open_tile, or spawn_list, which searches outward in rings and skips anything Map::blocks.
equip_silently returned false. Either the item has no Equipped component (it is not gear), or the slot is full – and on a world built by initialize_world the body and hand slots start full. force_unequip the incumbent first (Recipe 2, step 3).
The borrow checker refuses the RNG line. A resource_mut::<GameRng>() guard is still alive at the spawn. Roll inside a block and let it drop (Recipe 3, step 2).
Two things landed on the same tile. Only spawn_list and spawn_requested track occupancy, via the &mut HashSet<(u16, u16)> you pass in. Spawning in a loop yourself means keeping that set yourself.
A seeded test started failing after you added content. Floor layout is a pure function of (seed, depth) and cannot move – but the contents are drawn from a weighted table, so adding a row changes which rows a given roll lands on. Assert on what you spawned by name, not on what a seed happened to produce. models/tests/it/determinism.rs holds the line that matters.
Check yourself
cargo test --test it content:: # every name still spawns and keeps its name
cargo test --test it determinism:: # seeds still mean what they meant
cargo run -p nihilurk -- -content # what the game knows, live from the tables
Appendix: quick check
- Look at it now:
NIHILURK_SPAWN="<name>" cargo run -p nihilurk, with names from-content. - In a test:
spawn_named(&mut w, "<name>", Position { x, y }).unwrap()on a bareWorld. - Needs a floor: build the world with
initialize_world, asmodels/tests/it/identify.rsdoes. - In the pack:
stow(&mut w, player, item). Worn:force_unequipthe incumbent, thenequip_silently. - From game code: find a free tile, roll from
GameRngin a scoped block, then spawn throughspawn_monster,spawn_namedorTrapBundle::from_def. - Many at once:
spawn_list, which tracks occupancy. - Loot as the dungeon rolls it:
roll_item(world, &mut rng, depth, pos). - Run
cargo test --test it content::andcargo test --test it determinism::.
See also
../reference/spawn-api.md every signature on this page ../reference/cli-and-env.md NIHILURK_SPAWN and -content in full ../reference/content-tables.md the tables these functions read add-a-monster.md adding the row you want to spawn add-an-item.md the same, for items work-with-the-ecs.md spawning from inside a system play-through-a-pty.md look at it in the real game, from a script