Introduction
This guide builds a roguelike called Warren: a rat warren under a granary, two floors deep, dark except for the lantern you carry, with a way out at the bottom that ends the run.
Six steps, each a complete program you can run, and each one playable in this page without installing anything. CI compiles every one of them, so the code in these pages is code that builds.
Each step is the one before it plus a single new thing, and the sixth is a small game with floors, fighting, items, an ability and a way to win.
Two rules the API follows
Own the loop or leave it out. The engine holds the turn loop, the scheduler, field of view, the occupancy index, the damage pipeline and the map. Your game supplies the decisions: what a tile looks like, what a monster wants, what eating a crust means.
The engine never names your content.
There is no enum MonsterKind, no Tile::Wall, no DamageType::Fire.
Tiles, damage kinds, factions, statuses, item tags and abilities are opaque ids in registries you fill.
What you need first
Rust, and Bevy’s ECS: components, systems, resources, Query, Commands.
Not Bevy’s renderer, assets or scenes.
Warren draws with a glyph terminal the engine ships.
To build and run the steps yourself, or to start a game of your own from the template, the project’s readme has the three commands for each.
How the chapters work
Each chapter takes the previous step and adds one thing. Code in the text is pulled from the step’s source, and the full file is linked at the top of every chapter.
Panels arrive when there is something to put in them rather than all at once, and the last chapter says where the rest of the engine is.
Next: a map, and walking on it.
A map, and walking on it
Run it:
cargo run -p tutorial --bin step01_walkingSource:
step01_walking.rs
Loads about 8 MB
Generate a floor, hand it to the engine, draw it from the player’s point of view, and walk about on it.
The app
fn main() -> AppExit {
let mut app = App::new();
// What every game adds: the window and the glyph terminal, the turn
// loop, sight, the map across the whole terminal, and the UI base.
app.add_plugins(RoguelikePlugins::new("Warren", COLS, ROWS))
.insert_resource(Seed(RunSeed(7)))
.add_systems(NewRun, start)
// Once a frame, before the turns: whatever the player pressed becomes
// at most one intent, however many passes the turn loop then runs.
.add_systems(Update, player_input.in_set(EngineSet::Input));
app.run()
}
RoguelikePlugins is what every game adds, in one line.
It opens a window sized to an 80 by 40 cell glyph terminal and brings the plugins no game goes without: the glyph grid, the engine’s turn loop and map, field of view, the map view, particles, and the UI base.
Everything else is a plugin you name, starting in chapter 3, and nothing turns itself on because a resource happens to exist.
Leave out the WorldMap and play refuses to begin, listing everything missing at once with how to make each.
Seed is where all randomness comes from.
Every stream the engine draws on is derived from it, so the same number always builds the same warren.
NewRun is the schedule that starts a run, and the engine runs it again on a restart with the old run torn down first, which is why a game’s setup goes there instead of in Bevy’s Startup.
Tiles are ids
/// The warren's tiles, and how each one looks in full light.
struct Warren {
tiles: TileRegistry,
seed: RunSeed,
}
impl Warren {
fn new(seed: RunSeed) -> Self {
let mut tiles = TileRegistry::new();
tiles.register(TileProps::wall("earth")).unwrap();
tiles.register(TileProps::floor("dirt")).unwrap();
Self { tiles, seed }
}
/// Both colours of every tile, and how much each cell jitters from
/// its neighbours. The renderer derives darkness and memory from these.
fn appearance(&self) -> TileAppearance {
let mut look = TileAppearance::new();
let t = |name| self.tiles.expect(name);
look.set_varied(t("earth"), Cell::new('#', Color::srgb(0.78, 0.66, 0.50)).on(Color::srgb(0.34, 0.27, 0.21)), Vary::new(0.20, 0.05));
look.set_varied(t("dirt"), Cell::new('.', Color::srgb(0.66, 0.58, 0.45)).on(Color::srgb(0.18, 0.15, 0.12)), Vary::new(0.28, 0.06));
look
}
}
register returns a dense TileId; expect looks one up by name and panics if it is missing.
All the engine knows about a tile is whether it is walkable and whether it blocks sight.
There is no Tile::Wall to extend, so lava, glass or a tile only ghosts can cross needs no engine change.
TileAppearance holds what each id looks like in full light.
Both colours are authored because light multiplies them channel by channel and memory fades them.
Vary jitters each cell’s colour by a hash of its position, so the floor is not graph paper.
The floor is a chain of passes
/// How a floor is built. The engine calls this once, the first time
/// something enters the map, and keeps what comes back.
impl PlaceRules for Warren {
fn build(&self, _: MapId, _: Option<&WorldGraph>) -> Result<PlaceBuild, BuildError> {
let (wall, floor) = (self.tiles.expect("earth"), self.tiles.expect("dirt"));
let mut ctx = BaseContext::blank(84, 42, self.tiles.clone(), wall);
Chain::new()
.then(dungeon::Rooms { floor, attempts: 40, min_size: 5, max_size: 10, min_rooms: 6 })
.then(dungeon::RandomStart)
.run(&mut ctx, self.seed)?;
PlaceBuild::from_context(ctx)
}
}
PlaceRules::build is called once, the first time anything enters that map, and the result is kept for the run.
Rooms carves rectangles and joins them with corridors, and RandomStart picks a floor cell and reports it as the chain’s start point.
Each pass keys its own random stream off its name, so adding a pass later does not shift the numbers an earlier pass draws.
PlaceBuild::from_context reads the finished chain: the start point becomes the entry, an exit point becomes the exit if some pass emitted one, and prefab marks become spots to populate.
Handing it over
/// Hands the engine the map rules and the player, then warps the player in.
fn start(mut commands: Commands, seed: Res<Seed>, mut warps: MessageWriter<WarpRequest>, mut next: ResMut<NextState<EngineState>>) {
let warren = Warren::new(seed.0);
commands.insert_resource(warren.appearance());
commands.insert_resource(WorldMap::new(warren.tiles.tables()));
commands.insert_resource(PlaceRulesRes(Box::new(warren)));
let player =
commands.spawn(((Actor, Player, Blocks, Position(Point::ZERO)), (Viewshed::new(9), RevealsMap, Glyph::new('@', Color::WHITE).on_layer(10)))).id();
warps.write(WarpRequest::into_place(player, WARREN));
next.set(EngineState::Playing);
}
WorldMap::new takes the tile tables alone.
No world graph, no region size, no surface: a game with an overworld adds those, a delve never names them.
The player is components, not a class.
Actor takes turns, Player is the one the loop waits on for input, Blocks puts it in the occupancy index, RevealsMap marks whose sight fills in the explored map.
An intent, not a move
/// The player, but only while it is holding the turn.
type PlayerTurn<'w, 's> = Query<'w, 's, Entity, (With<Player>, With<MyTurn>)>;
/// Keys to intents. Writing an intent is the whole of asking to act: the
/// engine claims the turn, charges it, and refuses what cannot be done.
fn player_input(
keys: Res<ButtonInput<KeyCode>>,
dirs: Res<DirectionKeys>,
repeats: Res<Repeats>,
player: PlayerTurn,
mut steps: MessageWriter<Intent<Step>>,
mut waits: MessageWriter<Intent<Wait>>,
mut exit: MessageWriter<AppExit>,
) {
if keys.just_pressed(KeyCode::KeyQ) {
exit.write(AppExit::Success);
return;
}
// No turn in hand means it is somebody else's move; the key is dropped.
let Ok(entity) = player.single() else { return };
// A press walks, and a key held down keeps walking: `Repeats` is the
// engine's hold, already advanced before input is read.
if let Some(dir) = dirs.just_pressed(&keys).or_else(|| repeats.firing_any().map(|(d, _)| d)) {
steps.write(Intent::new(entity, Step(dir)));
} else if keys.just_pressed(KeyCode::Period) || keys.just_pressed(KeyCode::Numpad5) {
waits.write(Intent::new(entity, Wait));
}
}
Input never moves anybody.
It writes an Intent<Step> and stops.
The engine decides whether the actor may act, whether the move is legal, what it costs and what to do when it is not.
With<MyTurn> makes the query empty unless the player is holding a turn, so a key pressed while something else is moving is dropped.
DirectionKeys is the engine’s binding of the arrows, hjklyubn and the numpad to the eight directions, and just_pressed answers which one was struck.
It is a resource, so a game that wants other keys replaces it and writes no match statement of its own.
One pass of the turn loop
| Stage | What happens |
|---|---|
Schedule | The clock advances and one actor is dealt MyTurn |
Decide | Minds choose for everyone who is not the player |
Resolve | Intents become changes to the world |
Sweep | Anything nobody resolved is refused, with a warning naming it |
React | The game answers what the turn caused |
Cleanup | The actor is charged and requeued |
One actor holds a turn at a time and is out of the queue while it does.
Cleanup puts it back at now + cost.
Costs are hundredths of a normal step, and BASE_ACTION_COST is 100.
Speed(200) is twice as fast and the scheduler scales cost by it.
No floats in the clock, so a seed replays.
Walk into a wall and nothing happens: no time passes, and you keep the turn. A monster handed a free retry would spin forever, so a blocked monster is charged for a wait instead.
Try it
- Change
Viewshed::new(9)to4and watch the room close in. - Change the seed, then change it back and confirm you get the same floor.
- Give the player
Speed(200)and watch the turn counter climb half as fast.
Next: what you can see, and the dark.
What you can see, and the dark
Run it:
cargo run -p tutorial --bin step02_lightSource:
step02_light.rs
Loads about 8 MB
The warren goes dark, and you carry the only light in it.
Walkable and opaque are separate flags
/// The warren's tiles, and how each one looks in full light.
struct Warren {
tiles: TileRegistry,
seed: RunSeed,
}
impl Warren {
fn new(seed: RunSeed) -> Self {
let mut tiles = TileRegistry::new();
tiles.register(TileProps::wall("earth")).unwrap();
tiles.register(TileProps::floor("dirt")).unwrap();
// Walkable and opaque: you can step through a curtain of roots,
// but you cannot see past one until you do.
tiles.register(TileProps::floor("roots").opaque(true)).unwrap();
Self { tiles, seed }
}
/// Both colours of every tile, and how much each cell jitters from
/// its neighbours. The renderer derives darkness and memory from these.
fn appearance(&self) -> TileAppearance {
let mut look = TileAppearance::new();
let t = |name| self.tiles.expect(name);
look.set_varied(t("earth"), Cell::new('#', Color::srgb(0.78, 0.66, 0.50)).on(Color::srgb(0.34, 0.27, 0.21)), Vary::new(0.20, 0.05));
look.set_varied(t("dirt"), Cell::new('.', Color::srgb(0.66, 0.58, 0.45)).on(Color::srgb(0.18, 0.15, 0.12)), Vary::new(0.28, 0.06));
look.set_varied(t("roots"), Cell::new('+', Color::srgb(0.55, 0.74, 0.45)).on(Color::srgb(0.16, 0.22, 0.13)), Vary::new(0.18, 0.05));
look
}
}
Doors depend on it. A curtain of roots is walkable and opaque: you can step through one, but you cannot see past it until you do.
Field of view reads opacity off the tile tables through an OpacitySource, so a tile you invented five minutes ago blocks sight correctly.
Seen, and once seen
Viewshed carries two bit grids.
line is every tile with an unobstructed line to it, and visible is what the actor really sees.
They are the same set until lighting is on, when visible shrinks to what is lit, within dark sight, or adjacent.
Knowledge is the explored map, kept per map id and filled by whoever carries RevealsMap.
It is a resource, not a component: the player’s map is the game’s map.
The map view combines the three.
Visible tiles are drawn in their authored colours, explored-but-unseen ones run through Memory, and everything else is blank.
The cold blue of a remembered corridor is not a colour anyone chose. It is the brown floor, remembered.
A light to carry
/// What the lantern sheds when it is open: a warm, slightly restless pool.
const LANTERN: LightSource = LightSource::new(150, 7, Rgb::new(255, 210, 140)).flickering(30);
/// The player and whether its lantern is open, while it holds the turn.
type Lantern<'w, 's> = Query<'w, 's, (Entity, Has<LightSource>), (With<Player>, With<MyTurn>)>;
/// `t` opens the lantern or shades it, and spends the turn either way.
///
/// The light is a component on the player, so shading it is removing one.
/// Nothing else changes: sight is still sight, and the explored map still
/// remembers what the light once reached.
fn tend_lantern(
keys: Res<ButtonInput<KeyCode>>,
mut commands: Commands,
player: Lantern,
mut waits: MessageWriter<Intent<Wait>>,
mut log: ResMut<MessageLog>,
turns: Res<Turns>,
) {
if !keys.just_pressed(KeyCode::KeyT) {
return;
}
let Ok((entity, lit)) = player.single() else { return };
if lit {
commands.entity(entity).remove::<LightSource>();
log.muted("You shade the lantern. The warren closes to arm's length.", turns.turn_number());
} else {
commands.entity(entity).insert(LANTERN);
log.notice("You open the lantern. The dirt comes up warm around you.", turns.turn_number());
}
waits.write(Intent::new(entity, Wait));
}
Lighting::dark() is the whole of turning the lights off, and LightingPlugin is the subsystem that then matters.
A LightSource is a component, so shading the lantern is removing one and opening it is putting it back.
Sight is unchanged by any of this. What changes is which of the tiles in line are lit enough to resolve, which is why walking into the dark with the lantern shut still fills in the floor you are standing on.
Every actor has its own viewshed, cast the same way, so a monster that sheds no light is found only where a light reaches it. That cuts both ways, and chapter 3 gives the rats dark sight so they can still find you.
Try it
- Shade the lantern and walk a corridor. The explored map keeps what the light already reached.
- Change
LANTERN’s radius from 7 to 2 and feel the warren close in. - Give the lantern
Fueland watch it burn out.
Next: blows, and the log that tells you.
Blows, and the log that tells you
Run it:
cargo run -p tutorial --bin step03_blowsSource:
step03_blows.rs
Loads about 8 MB
Rats that hunt you in the dark, and a log that says what happened.
Combat, and the minds that choose it
fn main() -> AppExit {
let mut app = App::new();
// What every game adds: the window and the glyph terminal, the turn
// loop, sight, the map in everything but the status row and the log, and the UI base.
app.add_plugins(RoguelikePlugins::new("Warren", COLS, ROWS).map(Rect::new(0, 1, COLS, ROWS - 1 - LOG_ROWS)))
// Without this the world is lit everywhere and sight is geometry
// alone. With it, `visible` shrinks to what a light reaches.
.add_plugins((CombatPlugin, MindsPlugin, LightingPlugin))
.insert_resource(Lighting::dark())
.insert_resource(Seed(RunSeed(7)))
// Two panels: the vitals strip on the top row, the log along the
// bottom. Each draws itself; neither needs a system of yours.
.add_plugins(VitalsPanel::new(Rect::new(0, 0, COLS, 1)).hints("[t]orch [.]wait [q]uit"))
.add_plugins(LogPanel::new(Rect::new(0, ROWS - LOG_ROWS, COLS, LOG_ROWS)))
// The engine narrates blows, deaths and pickups into the log, naming
// things in their own colours. Warren changes one phrase: what a rat
// does to you is a bite.
.add_plugins(NarratorPlugin::default().phrase(Phrase::HitsYou, "{Who} bites you for {n}.", Tones::BAD))
// Escape opens the menu, and the run's end opens it by itself.
.add_plugins(GameMenuPanel::new(Rect::new(COLS / 2 - 20, 8, 40, 12)).died("The warren keeps you."))
.add_systems(NewRun, start)
// A floor fills the first time it is entered, inside the turn.
.add_systems(Turn, populate.in_set(TurnSet::React))
// Once a frame, before the turns: whatever the player pressed becomes
// at most one intent, however many passes the turn loop then runs.
.add_systems(Update, (player_input, tend_lantern).in_set(EngineSet::Input))
.add_systems(Update, note_explored.in_set(ViewSet::Annotate));
app.run()
}
Deciding where to move and deciding whom to hit are the same decision, asked of the same priority list.
MindsPlugin owns that decision and CombatPlugin owns what a blow does once it is struck, so a monster that thinks needs both.
Forget MindsPlugin and the first monster spawned says so in the log, instead of standing still all run.
Two panels arrive here, and neither needs a system of yours.
VitalsPanel reads health, armor, the turn and the position off the player and prints them along the top row; LogPanel prints the log along the bottom.
Each is a plugin holding the rectangle it draws in, the way the map view is.
What a hit passes through
/// Hands the engine the map rules and the player, then warps the player in.
fn start(
mut commands: Commands,
seed: Res<Seed>,
mut warps: MessageWriter<WarpRequest>,
mut log: ResMut<MessageLog>,
mut next: ResMut<NextState<EngineState>>,
) {
let warren = Warren::new(seed.0);
// The two registries combat reads: what damage can be, and who hates
// whom. Both are the game's content, named nowhere in the engine.
let kinds = Registry::from_defs(vec![DamageKind::new("bite"), DamageKind::new("kick")]).unwrap();
let sides = Registry::from_defs(vec![FactionDef::new("you"), FactionDef::new("vermin")]).unwrap();
let (you, vermin) = (sides.expect("you"), sides.expect("vermin"));
commands.insert_resource(CombatRules::new(&sides).hostile(you, vermin));
commands.insert_resource(Registries { damage_kinds: kinds.clone(), factions: sides, ..default() });
// What a hit passes through on its way to the target. One stage here;
// resistances, a shield, a critical rule would each be another.
commands.insert_resource(DamageStages(vec![Box::new(SubtractArmor)]));
commands.insert_resource(Rats {
// Asked in order, first that answers wins: bite what is next to
// you, run when badly hurt, chase what you can see, else mill about.
mind: Arc::new(Brain::new().then(MeleeAdjacent).then(FleeWhenHurt { at_pct: 30 }).then(Hunt).then(Wander { chance_pct: 40 })),
bite: kinds.expect("bite"),
faction: vermin,
});
commands.insert_resource(warren.appearance());
commands.insert_resource(WorldMap::new(warren.tiles.tables()));
commands.insert_resource(PlaceRulesRes(Box::new(warren)));
let player = commands
.spawn((
(Actor, Player, Blocks, Position(Point::ZERO)),
(Viewshed::new(9), RevealsMap, LANTERN, Faction(you), Glyph::new('@', Color::WHITE).on_layer(10)),
(Health::full(24), Armor(1), MeleeAttack::new(kinds.expect("kick"), DiceRoll::new(1, 6))),
))
.id();
warps.write(WarpRequest::into_place(player, WARREN));
log.push(format!("Seed {}. You squeeze into the warren.", seed.0.0), Tones::NOTICE, 0);
log.push("Something is scratching in the dark.", Tones::MUTED, 0);
next.set(EngineState::Playing);
}
Damage kinds and factions are registries, like tiles.
They go in Registries, the one resource every subsystem reads its registries from.
CombatRules is only who is hostile to whom, held as a matrix over pairs instead of a flag on a monster, so a three-way war costs nothing extra.
DamageStages is what a hit passes through on its way to the target.
Warren has one stage, SubtractArmor.
Resistances by damage kind, a shield that eats the first hit each turn, a critical rule reading the attacker’s stats: each is another entry in that list, in the order you put them.
Combat rolls from a stream the engine derives from the run’s Seed, which main inserted in chapter 1.
A game never inserts a stream of its own.
A brain is a priority list
/// What every rat in the warren shares: one brain, one faction, one bite.
#[derive(Resource)]
struct Rats {
mind: Arc<Brain<Entity>>,
bite: rl_engine::rl_rules::damage::DamageKindId,
faction: FactionId,
}
Tactics are asked in order and the first that answers wins, so the reading order is the behaviour. Bite what is next to you, run when badly hurt, chase what you can see, else mill about.
The brain holds no state about any particular rat, so sixteen rats share one Arc.
What a tactic needs is passed in: a snapshot of what that actor can see, its health, its position.
That snapshot is cut to the actor’s own Viewshed, and then to its Perception, which is how far its mind considers what it sees.
Hunt does not pathfind per rat per turn.
It asks the engine for the way toward the enemies it sees, and the engine keeps one Dijkstra flow field per set of goals and movement class, so sixteen rats after one player read their downhill step off one flood.
One key, three actions
The walk keys no longer write a Step.
They write a Bump, and the engine decides what a bump comes to: a step onto open ground, a blow at a foe standing there, or the door in the way opened.
That is an alternate action, read in ResolveSet::Redirect, the stage before any resolver claims the turn.
The narrator
The engine reads every event it raises and says what happened, inside the turn, one pass at a time, so a frame in which three rats act reads in the order they acted.
Each event becomes a Phrase, split by who did what to whom, and the Phrasebook holds one template and one tone for each.
Warren changes exactly one: what a rat does to you is a bite.
A name in a template is drawn in the colour of the thing it names, so the rat in The rat bites you for 2. is the rat’s own brown.
Take a rat’s Name off and the log says something.
Ending the run
When the player dies the engine writes RunOver, leaves EngineState::Playing, and GameMenuPanel opens by itself under the words Warren gave it, offering a new run or the same seed again.
The screen it opens says the outcome, the seed and the turn; a game with more to say pushes a section onto EndingView and the screen draws it.
Try it
- Add a stage that halves every hit and read the log to confirm the order.
- Reverse
MeleeAdjacentandHuntand watch rats walk past you. - Take
DarkSightoff the rats and hunt them with the lantern shaded.
Next: things to carry, throw and eat.
Things to carry, throw and eat
Run it:
cargo run -p tutorial --bin step04_thingsSource:
step04_things.rs
Loads about 8 MB
Bread that mends, rocks that fly, and monsters clever enough to use both.
An item is components
/// What the floor is littered with: bread that mends, rocks that fly.
fn litter(commands: &mut Commands, rats: &Rats, p: Point, bread: bool) {
if bread {
commands.spawn((Item, Crust(8), Name::new("a crust of bread"), Position(p), Glyph::new('%', Color::srgb(0.85, 0.72, 0.40)).on_layer(2)));
} else {
commands.spawn((
Item,
Throwable { range: 7, strike: Some((rats.bite, DiceRoll::new(1, 4))) },
Name::new("a rock"),
Position(p),
Glyph::new('*', Color::srgb(0.66, 0.66, 0.70)).on_layer(2),
));
}
}
Item says the engine may move it between the ground, a bag and a slot.
Position means it is lying on the floor; picking it up removes that component and dropping it puts one back.
Crust(8) is yours, and the engine has no opinion about it.
Throwable is the engine’s, and it carries the two facts a throw needs: how far it reaches, and what it does to whoever it hits.
A throw is both an item and a blow, so it is its own plugin: the rock leaves the bag the way a dropped one does, and whoever it strikes is hurt down the same damage pipeline a bite goes down.
Keys, and a cursor you did not write
/// The player, but only while it is holding the turn, and what it carries.
type PlayerTurn<'w, 's> = Query<'w, 's, (Entity, Option<&'static Inventory>), (With<Player>, With<MyTurn>)>;
/// Keys to intents. Writing an intent is the whole of asking to act: the
/// engine claims the turn, charges it, and refuses what cannot be done.
///
/// The walk keys write a [`Bump`], which the engine resolves to a step, a
/// blow at a foe, or opening a door, whichever is in the way. `r` writes no
/// intent at all: it opens the engine's aiming cursor, and the throw is
/// written when the cursor is committed.
fn player_input(
keys: Res<ButtonInput<KeyCode>>,
dirs: Res<DirectionKeys>,
repeats: Res<Repeats>,
player: PlayerTurn,
carried: Carried,
mut intents: PlayerIntents,
) {
if keys.just_pressed(KeyCode::KeyQ) {
intents.exit.write(AppExit::Success);
return;
}
// No turn in hand means it is somebody else's move; the key is dropped.
let Ok((entity, bag)) = player.single() else { return };
// A press walks, and a key held down keeps walking: `Repeats` is the
// engine's hold, already advanced before input is read.
if let Some(dir) = dirs.just_pressed(&keys).or_else(|| repeats.firing_any().map(|(d, _)| d)) {
intents.bumps.write(Intent::new(entity, Bump(dir)));
} else if keys.just_pressed(KeyCode::KeyG) {
intents.pick_ups.write(Intent::new(entity, PickUp));
} else if keys.just_pressed(KeyCode::KeyE) {
if let Some(crust) = bag.into_iter().flat_map(|b| b.items.iter().copied()).find(|i| carried.crusts.contains(*i)) {
intents.uses.write(Intent::new(entity, UseItem(crust)));
}
} else if keys.just_pressed(KeyCode::KeyR) {
if let Some(rock) = bag.into_iter().flat_map(|b| b.items.iter().copied()).find(|i| carried.rocks.contains(*i)) {
intents.aims.write(AimThrow { user: entity, item: rock });
}
} else if keys.just_pressed(KeyCode::Period) || keys.just_pressed(KeyCode::Numpad5) {
intents.waits.write(Intent::new(entity, Wait));
}
}
g and e write intents, the same as walking does.
r writes no intent at all.
It writes an AimThrow, and the engine opens its targeting cursor, previews the flight with the same function the resolver throws with, and writes the throw when you commit.
The cells you are shown are the cells the rock will fly through.
Eating is the pattern worth keeping.
UseItem checks the item is in the bag, spends the turn and writes ItemEvent::Used.
It does not heal, teleport or explode; what eating a crust means is the game’s:
/// What eating a crust means. The engine has already spent the turn and
/// taken the item out of the bag; this is the part only the game knows.
///
/// It runs in [`TurnSet::React`], inside the turn, so the healing lands
/// before the next rat is dealt its move. In the drawing phase it would
/// land a blow too late.
fn eat(mut commands: Commands, mut used: MessageReader<ItemEvent>, crusts: Query<&Crust>, mut eaters: Query<&mut Health>) {
for ev in used.read() {
let ItemEvent::Used { actor, item } = *ev else { continue };
let (Ok(crust), Ok(mut health)) = (crusts.get(item), eaters.get_mut(actor)) else { continue };
health.current = (health.current + crust.0).min(health.max);
commands.entity(item).despawn();
}
}
That runs in TurnSet::React, inside the turn, so the healing lands before the next rat is dealt its move.
In the drawing phase it would land a blow too late.
How clever a monster is
/// Fills the floor the one time it is built. `PlaceEntered::first` is
/// true only on that arrival, so coming back does not restock it.
fn populate(mut commands: Commands, mut entered: MessageReader<PlaceEntered>, rats: Res<Rats>, map: Res<WorldMap>, seed: Res<Seed>) {
for ev in entered.read() {
if !ev.first {
continue;
}
let Some(place) = map.place(ev.map) else { continue };
let bounds = place.terrain.bounds();
// A stream of its own, keyed by name: adding another spawner later
// cannot shift the numbers this one draws.
let mut rng = seed.stream(b"warren.rats", ev.map.0 as u64);
let mut placed = 0;
while placed < 16 {
let p = Point::new(rng.random_range(bounds.x..bounds.right()), rng.random_range(bounds.y..bounds.bottom()));
// Not on top of the player, and not close enough to be unfair.
if !map.is_walkable(p) || geometry::chebyshev(p, ev.entry) < 8 {
continue;
}
// Every fourth is a ratling: the same body, a better mind.
let clever = placed % 4 == 3;
let mut e = commands.spawn((
(Actor, Blocks, Position(p), Speed(110), Faction(rats.faction)),
(Health::full(6), Armor(0), Perception(7), DarkSight(9)),
(MeleeAttack::new(rats.bite, DiceRoll::new(1, 3)),),
));
if clever {
e.insert((
Mind(rats.ratling.clone()),
// Wits are what a mind is allowed to consider. A ratling
// opens doors, fetches what it can throw, and throws it.
Intelligence(Wits::SAPIENT),
Inventory::default(),
Glyph::new('R', Color::srgb(0.85, 0.66, 0.50)).on_layer(5),
Name::new("ratling"),
));
} else {
e.insert((
Mind(rats.mind.clone()),
// An animal flees and searches, and that is all.
Intelligence(Wits::ANIMAL),
Glyph::new('r', Color::srgb(0.72, 0.55, 0.45)).on_layer(5),
Name::new("rat"),
));
}
placed += 1;
}
// Bread to mend with and rocks to throw, scattered the same way.
let mut dropped = 0;
while dropped < 14 {
let p = Point::new(rng.random_range(bounds.x..bounds.right()), rng.random_range(bounds.y..bounds.bottom()));
if !map.is_walkable(p) {
continue;
}
litter(&mut commands, &rats, p, dropped % 2 == 0);
dropped += 1;
}
}
}
Intelligence is a set of wits, and it decides what a mind is allowed to consider.
Wits::MINDLESS fights to the death and forgets what it cannot see.
Wits::ANIMAL flees when hurt and searches where it last saw you, and that is all a rat is.
Wits::SAPIENT adds opening doors, picking things up, wearing them and throwing them, which is what makes a ratling worth being afraid of.
The tactics come from the same list either way.
A ratling’s brain has ThrowAtRange above Hunt, so it stops and throws when it has something to throw and you are not already at its elbow, and Scavenge, so it will walk a few steps out of its way to fetch a rock it can throw later.
Wits are the gate: Scavenge will not fetch what its wits say it can never use.
Try it
- Give the rats
Wits::SAPIENTand watch a rock come back at you. - Drop
Scavengefrom the ratling brain and see how much less dangerous a stocked floor becomes. - Give the crust a
Stack { key, count }, drop six in one cell, and pick them all up at once.
Next: a knack of your own.
A knack of your own
Run it:
cargo run -p tutorial --bin step05_knackSource:
step05_knack.rs
Loads about 8 MB
One ability for you, one for the ratlings, and both of them written in a file rather than in Rust.
An ability is data
// The one knack the warren gives you.
//
// Every field (the ones marked "optional" may be left out):
// name: unique; what the game looks it up by
// description: optional; what it is, in a sentence, for the knack list
// look: optional; (glyph:, color: (r:, g:, b:)), what flies and bursts
// aim: Foe (default) | Ally | SelfOnly | Ground | Anyone
// mode: Own | Adjacent | Bolt(range:) | Ball(range:, radius:) |
// Beam(range:) | Cone(length:)
// costs: optional; this one costs no pool, only the wait
// cooldown: optional; hundredths of a step before it may be used again
// effects: each (kind:, chance:, args:); "Harm" takes a damage kind
// this game registered and a dice roll
#![enable(implicit_some)]
[
(
name: "screech",
description: "A shriek that goes through a rat like a nail. It carries, and it hurts.",
look: (glyph: '*', color: (r: 220, g: 220, b: 255)),
aim: Foe,
mode: Ball(range: 6, radius: 1),
cooldown: 300,
effects: [(kind: "Harm", args: (kind: "din", roll: "1d6"))],
),
(
name: "gnaw",
description: "Eat the crust you are carrying. A ratling knows to do this; a rat does not.",
aim: SelfOnly,
mode: Own,
costs: [Item("bread", 1)],
effects: [(kind: "Mend", args: (kind: "care", roll: "1d6"))],
),
]
An aim, a shape, what it costs, how long before it may be used again, and a list of effects.
The engine ships seven effects and a game registers its own beside them with add_effect, so add_engine_effects is what makes Harm and Mend names this file may use.
The shape is the engine’s targeting footprint: Ball(range, radius) here, and Bolt, Beam, Cone, Adjacent and Own beside it.
Nothing about the shape is written twice, because the cursor previews with the same footprint the resolver lands the effects with.
Loading it, and knowing it
/// The knacks this game loads, and the one the player is given.
const KNACKS_RON: &str = include_str!("../../assets/knacks.ron");
/// The ability ids the game holds on to, so a key can name one.
#[derive(Resource)]
struct Knacks {
screech: AbilityId,
}
Abilities::load checks the file against the registries and the effects the app registered, so a knack naming a damage kind nobody registered fails at start-up rather than three floors down.
The player is given the knack with Grants.
Known is derived from Grants, what is worn and what is carried, every turn, so an item that grants an ability lends it for exactly as long as it is held.
A key that only aims
The key writes AimAt { user, ability } and stops.
The cursor, the preview of what the burst would cover, the check that you can afford it and the spending of the turn are all the engine’s.
That is the same shape as throwing a rock in chapter 4, because it is the same cursor.
The one a monster uses
The ratlings get a knack too, and it is how they eat.
gnaw costs Item("bread", 1) and mends what it heals, so a ratling that carries a crust can spend it to patch itself up.
The cost is paid from the bag by tag, which is what the Tagged component on the crust is for.
Their brain gains UseAbility, which scores what an ability’s footprint would land on rather than learning what any particular ability does.
A ratling at full health gains nothing by eating, so it does not bother; a hurt one does.
Nothing in the engine knows what bread is.
Try it
- Add a second knack to the file and give it to the player. No Rust changes.
- Change
screechfromBall(range: 6, radius: 1)toCone(length: 5)and watch the preview change with it. - Give the player
gnawas well and eat with an ability instead ofe.
Next: two floors, and a way out.
Two floors, and a way out
Run it:
cargo run -p tutorial --bin step06_descentSource:
step06_descent.rs
Loads about 8 MB
Stairs down, a second floor built a different way, and daylight that ends the run.
A place is a map that is kept
/// How deep the warren goes before it lets you out again.
const FLOORS: u32 = 2;
/// Map zero is the streamed surface, which the warren has none of, so its
/// floors are maps one upward.
fn map_of(floor: u32) -> MapId {
MapId(floor)
}
/// The floor a map id is.
fn floor_of(map: MapId) -> u32 {
map.0
}
/// What a floor is called.
fn name_of(floor: u32) -> &'static str {
match floor {
1 => "the Burrow",
_ => "the Deep Nest",
}
}
Map zero is the streamed surface, which the warren has none of, so its floors are maps one upward.
Everything above zero is a place: built the first time it is entered, then kept. Leaving freezes the actors and items where they stand, so the rat you ran from is still in the corridor at the health you left it. Actors on other maps are skipped by the scheduler instead of simulated, so a deep run costs no more per turn than a shallow one.
One rules object, two floors
/// How a floor is built. The engine calls this once, the first time
/// something enters the map, and keeps what comes back.
impl PlaceRules for Warren {
fn build(&self, map: MapId, _: Option<&WorldGraph>) -> Result<PlaceBuild, BuildError> {
let depth = floor_of(map);
let (wall, open, roots) = (self.tiles.expect("earth"), self.tiles.expect("dirt"), self.tiles.expect("roots"));
let mut ctx = BaseContext::blank(84, 42, self.tiles.clone(), wall);
// A stream per floor, so the second floor is the same whether or
// not you dawdled on the first.
let seed = RunSeed(self.seed.0 ^ (depth as u64) << 32);
let chain = match depth {
// Dug rooms near the surface.
1 => Chain::new().then(dungeon::Rooms { floor: open, attempts: 40, min_size: 5, max_size: 10, min_rooms: 6 }).then(dungeon::Doors { door: roots }),
// Gnawed-out caves under them.
_ => Chain::new().then(passes::CellularCave { wall, floor: open, fill_pct: 45, ..Default::default() }).then(passes::KeepLargestRegion { wall }),
};
// Every floor gets a start and a point as far from it as the floor
// allows: the stairs down on the first, the way out on the second.
chain.then(dungeon::RandomStart).then(dungeon::FarthestExit).run(&mut ctx, seed)?;
PlaceBuild::from_context(ctx)
}
}
build is handed the MapId, so the shape of the run is a match.
Dug rooms near the surface, a gnawed-out cave under them, and KeepLargestRegion after the cave because cave generators produce islands and an unreachable half is a floor whose stairs are sometimes unreachable.
The seed is derived per floor, so the second floor is the same whether or not you dawdled on the first. Derive from the run seed and the thing being built, never from a counter the player can move.
Stairs are entities
/// Fills the floor the one time it is built. `PlaceEntered::first` is
/// true only on that arrival, so coming back does not restock it.
fn populate(mut commands: Commands, mut entered: MessageReader<PlaceEntered>, rats: Res<Rats>, map: Res<WorldMap>, seed: Res<Seed>) {
for ev in entered.read() {
if !ev.first {
continue;
}
let Some(place) = map.place(ev.map) else { continue };
let bounds = place.terrain.bounds();
// A stream of its own, keyed by name: adding another spawner later
// cannot shift the numbers this one draws.
let mut rng = seed.stream(b"warren.rats", ev.map.0 as u64);
let mut placed = 0;
while placed < 16 {
let p = Point::new(rng.random_range(bounds.x..bounds.right()), rng.random_range(bounds.y..bounds.bottom()));
// Not on top of the player, and not close enough to be unfair.
if !map.is_walkable(p) || geometry::chebyshev(p, ev.entry) < 8 {
continue;
}
// Every fourth is a ratling: the same body, a better mind.
let clever = placed % 4 == 3;
// A ratling carries its own supper. Nothing picks bread up off
// the floor: `Scavenge` fetches gear and missiles, not meals.
let supper = clever.then(|| {
commands
.spawn((
Item,
Crust(6),
Tagged(vec![rats.bread]),
Name::new("a crust of bread"),
Glyph::new('%', Color::srgb(0.85, 0.72, 0.40)).on_layer(2),
))
.id()
});
let mut e = commands.spawn((
(Actor, Blocks, Position(p), Speed(110), Faction(rats.faction)),
(Health::full(6), Armor(0), Perception(7), DarkSight(9)),
(MeleeAttack::new(rats.bite, DiceRoll::new(1, 3)),),
));
if clever {
e.insert((
Mind(rats.ratling.clone()),
// Wits are what a mind is allowed to consider. A ratling
// opens doors, fetches what it can throw, and throws it.
Intelligence(Wits::SAPIENT),
Inventory { items: supper.into_iter().collect() },
Grants(vec![rats.gnaw]),
Glyph::new('R', Color::srgb(0.85, 0.66, 0.50)).on_layer(5),
Name::new("ratling"),
));
} else {
e.insert((
Mind(rats.mind.clone()),
// An animal flees and searches, and that is all.
Intelligence(Wits::ANIMAL),
Glyph::new('r', Color::srgb(0.72, 0.55, 0.45)).on_layer(5),
Name::new("rat"),
));
}
placed += 1;
}
// Stairs down while there is a floor below, and the way out on the
// last one. Both are entities standing on a cell, nothing more.
let depth = floor_of(ev.map);
let stone = Color::srgb(0.80, 0.80, 0.86);
if let Some(far) = ev.exit {
if depth < FLOORS {
commands.spawn((
Position(far),
Transition { to: Destination::Place { map: map_of(depth + 1), arrive: Arrive::Entry } },
Name::new("stairs down"),
Glyph::new('>', stone).on_layer(1),
));
} else {
commands.spawn((Position(far), WayOut, Name::new("a crack of daylight"), Glyph::new('<', Color::srgb(1.0, 0.95, 0.70)).on_layer(1)));
}
}
// Bread to mend with and rocks to throw, scattered the same way.
let mut dropped = 0;
while dropped < 14 {
let p = Point::new(rng.random_range(bounds.x..bounds.right()), rng.random_range(bounds.y..bounds.bottom()));
if !map.is_walkable(p) {
continue;
}
litter(&mut commands, &rats, p, dropped % 2 == 0);
dropped += 1;
}
}
}
There is no stairs table and no special tile flag.
A Transition names where it leads and how you arrive, and GoThrough takes the one you are standing on.
Nothing stops you putting one on a rat.
Winning
/// The crack of daylight on the last floor. Standing on it ends the run.
#[derive(Component, Clone, Copy)]
struct WayOut;
/// Winning is a component and an `if`. It runs inside the turn, so the run
/// ends on the step that reached the daylight and not a frame later.
fn leave(player: Query<(&Position, Option<&OnMap>), With<Player>>, ways: Query<(&Position, Option<&OnMap>), With<WayOut>>, mut over: MessageWriter<RunOver>) {
let Ok((at, on)) = player.single() else { return };
for (way, way_on) in &ways {
if way.0 == at.0 && way_on.map(|m| m.0) == on.map(|m| m.0) {
over.write(RunOver::won().saying("You come up into the roots, and the scratching stays below."));
}
}
}
A victory condition is a component and an if.
RunOver::won() ends the run the way the player’s death does: the menu opens over the last frame with Warren’s words above it, and no way back into it.
It runs inside the turn, so the run ends on the step that reached the daylight rather than a frame later. Where to go next points at the quest system, which is this with the objectives in a file.
Try it
- Add a third floor. You should only touch
FLOORS,name_ofand thematch. - Put a one-way
Transitionback to the first floor at the bottom. - Go down, kill a rat, come back up, go down again. The rat stays dead.
Next: content in files.
Content in files
Run it:
cargo run -p tutorial --bin step08_contentSource:
step08_content.rs
Warren has one kind of rat because a spawn call was hard-coded.
Move the bestiary into a file and the game stops needing a recompile to gain a monster.
The file
Every RON schema in this repository lists its full option space at the top, because the file is the interface.
#![enable(implicit_some)]
// What lives in the warren, floor by floor.
//
// Every field:
// name: unique; the log calls it this
// glyph: one character
// color: (r, g, b) in 0..=1
// hp: maximum health
// armor: flat damage removed from each hit
// attack: dice notation, "NdS+B" or a flat number
// kind: the damage kind it deals, by the name the game registered: "bite" | "venom"
// perception: tiles at which it notices you
// speed: percent, 100 normal
// flee_at: percent health at or below which it runs; 0 never flees
// shoves: optional; whether it shoves you back a cell instead of biting
// when you stand beside it
// spawn: (first_floor, last_floor, weight, min_group, max_group);
// a weight of 0 keeps it out of the table, for anything the
// game places by hand
[
(name: "rat", glyph: 'r', color: (0.72, 0.55, 0.45), hp: 6, armor: 0, attack: "1d3", kind: "bite", perception: 7, speed: 110, flee_at: 30, spawn: (1, 3, 6, 2, 4)),
(name: "grey rat", glyph: 'r', color: (0.62, 0.64, 0.68), hp: 9, armor: 1, attack: "1d4", kind: "bite", perception: 8, speed: 100, flee_at: 20, spawn: (2, 4, 4, 1, 3)),
(name: "root adder", glyph: 's', color: (0.45, 0.78, 0.42), hp: 7, armor: 0, attack: "1d5+1", kind: "venom", perception: 6, speed: 130, flee_at: 0, spawn: (2, 4, 3, 1, 2)),
(name: "warren hog", glyph: 'h', color: (0.85, 0.60, 0.55), hp: 18, armor: 2, attack: "1d6", kind: "bite", perception: 5, speed: 90, flee_at: 0, shoves: true, spawn: (3, 4, 2, 1, 1)),
(name: "rat king", glyph: 'R', color: (0.95, 0.78, 0.35), hp: 40, armor: 2, attack: "2d4", kind: "bite", perception: 12, speed: 100, flee_at: 0, spawn: (0, 0, 0, 1, 1)),
]
The struct
/// One kind of vermin, exactly as `assets/rats.ron` writes it. Serde
/// parses the file; [`Named`] is how the registry knows what to key it by,
/// and a [`NameRef`] is a name in the file that the load turns into an id.
#[derive(Debug, Clone, Deserialize)]
struct RatDef {
name: String,
glyph: char,
color: (f32, f32, f32),
hp: i32,
armor: i32,
attack: DiceRoll,
kind: NameRef<DamageKind>,
perception: i32,
speed: u32,
flee_at: i32,
spawn: (i32, i32, u32, u32, u32),
}
impl Named for RatDef {
fn name(&self) -> &str {
&self.name
}
}
/// What an entity on the map was spawned from.
#[derive(Component, Clone, Copy)]
struct Kind(Id<RatDef>);
Named tells the registry what to key an entry by.
DiceRoll deserializes straight from "1d5+1".
NameRef<DamageKind> is the interesting one: in the file it is a name, kind: "venom", and by the time the game holds a RatDef it is the id of a damage kind.
Names become ids at load
/// The bestiary: the defs, one brain per def, and the table that says
/// what belongs at what depth.
#[derive(Resource)]
struct Bestiary {
defs: Registry<RatDef>,
table: BandedTable<Id<RatDef>>,
minds: Vec<Arc<Brain<Entity>>>,
faction: FactionId,
}
impl Bestiary {
/// Reads the file against `names`, builds a brain for each entry from its own fields,
/// and bands the ones with a weight into the spawn table.
fn load(names: &Names, faction: FactionId) -> Self {
let defs: Registry<RatDef> = names.load(RATS_RON).unwrap_or_else(|e| panic!("assets/rats.ron: {e}"));
let mut table = BandedTable::default();
let mut minds = Vec::new();
for (id, def) in defs.iter() {
let (first, last, weight, group_min, group_max) = def.spawn;
if weight > 0 {
table.push(BandedEntry::new(id).bands(first, last).weight(weight).group(group_min, group_max));
}
let mut brain = Brain::new().then(MeleeAdjacent);
if def.flee_at > 0 {
brain = brain.then(FleeWhenHurt { at_pct: def.flee_at });
}
minds.push(Arc::new(brain.then(Hunt).then(Wander { chance_pct: 40 })));
}
Self { defs, table, minds, faction }
}
/// Spawns one of `id` at `at`.
fn spawn(&self, commands: &mut Commands, id: Id<RatDef>, at: Point) -> Entity {
let def = self.defs.get(id);
commands
.spawn((
(Actor, Blocks, Kind(id), Position(at), Speed(def.speed), Faction(self.faction)),
(Health::full(def.hp), Armor(def.armor), Perception(def.perception), Mind(self.minds[id.index()].clone())),
(MeleeAttack::new(def.kind.id(), def.attack), Glyph::new(def.glyph, Color::srgb(def.color.0, def.color.1, def.color.2)).on_layer(5)),
// What the narrator, and later the rail, call it.
(Name::new(def.name.clone()),),
))
.id()
}
}
names.load parses and checks: names unique, every entry well formed, and every name of other content found.
It fails at start-up naming the file, not three floors down.
The names come from Registries, the resource start fills with the damage kinds and the sides before anything is loaded against them:
commands.insert_resource(Bestiary::load(®istries.names(), vermin));
commands.insert_resource(registries);
Misspell a kind and the run stops at start-up with root adder: unknown damage kind "vemon", every unknown name in the file reported at once under the creature it is in.
They are the same tables the engine reads, so the kind a file was checked against is the kind combat mitigates.
A game’s own registries join the lookup the same way: names.with("item", &items) lets a creature name what it drops.
Nothing in RatDef is a string that could still be wrong, and nothing spawns a rat by looking a name up.
What comes back is an Id<RatDef>: a dense index, so minds[id.index()] is an array lookup, and typed, so it cannot be passed where an Id<ItemDef> belongs.
Note what the loop does with flee_at.
It builds a different brain per definition, from that definition’s fields, so a rat that never flees gets a brain with no FleeWhenHurt in it, instead of one that checks a flag every turn.
The file decides the shape of the brain, not only its numbers.
Bands say what belongs where
table.push(BandedEntry::new(id).bands(first, last).weight(weight).group(group_min, group_max));
let Some((&id, count)) = bestiary.table.pick_group(depth as i32, &mut rng) else { break };
A BandedTable entry has a depth range, a weight and a group size, so the difficulty curve is four numbers per line in a file.
Rats thin out below floor three, hogs only appear on the last two, adders come in ones and twos.
Weight zero keeps an entry out of the table, which is how the king lives in the same file and is still placed by hand:
bestiary.spawn(&mut commands, bestiary.defs.expect("rat king"), throne);
rl-rules ships a threat score and a band report over this same table, and cargo run -p corsair -- --balance prints it.
The tiles too
The largest block of Rust in chapter 1 was three lines of colour literals. Colour is content as well, so it goes in a file of the same shape:
// How the warren's tiles look in full light. The renderer works out
// darkness and memory from these two colours.
//
// Every field (the ones marked "optional" may be left out):
// tile: the tile's registered name; every registered tile needs a line,
// and the load says which is missing
// glyph: one character
// fg: (r, g, b) in 0..=1, the glyph's colour
// bg: optional; (r, g, b), the cell's fill; black when left out
// vary: optional; (brightness, hue), how far each cell strays from the
// authored colour, 0..=1 each; none when left out
// shimmer: optional; brightness drifting over time, 0..=1, for water and
// anything else that should not sit still
[
(tile: "earth", glyph: '#', fg: (0.78, 0.66, 0.50), bg: (0.34, 0.27, 0.21), vary: (0.20, 0.05)),
(tile: "dirt", glyph: '.', fg: (0.66, 0.58, 0.45), bg: (0.18, 0.15, 0.12), vary: (0.28, 0.06)),
(tile: "roots", glyph: '+', fg: (0.55, 0.74, 0.45), bg: (0.16, 0.22, 0.13), vary: (0.18, 0.05)),
]
/// Both colours of every tile, and how much each cell strays from its
/// neighbours, read from `assets/tiles.ron` against the tiles registered
/// above. A tile the file forgets is reported at startup, by name.
fn appearance(&self) -> TileAppearance {
TileAppearance::load(TILES_RON, &self.tiles).unwrap_or_else(|e| panic!("assets/tiles.ron: {e}"))
}
The load is checked against the registry the same way the bestiary is checked against the damage kinds.
A tile the file forgets, a name it misspells or a tile it describes twice stops the run at start-up with every problem listed, instead of showing up as a magenta question mark three floors down.
What a tile is stays in Warren::new, because the engine reads that; what it looks like is the renderer’s business and the file’s.
Try it
- Add a monster of your own to
rats.ron. You will not touch a Rust file. - Give something
spawn: (1, 4, 20, 6, 10)and meet a swarm. - Break the file on purpose, by duplicating a name, writing
"1z6", or giving a rat akindnobody registered, and read the error. - Recolour the roots in
tiles.ron, then delete the line and read what the load says.
Next: an action of your own.
An action of your own
Run it:
cargo run -p tutorial --bin step09_shoveSource:
step09_shove.rs
Everything so far used actions the engine ships. This one it has never heard of: shove a rat back a cell, for half a turn.
An action is a type
/// Shove whoever stands one cell away in this direction back another cell.
///
/// An action is a type. There is no list in the engine for it to be added
/// to; registering it makes `Intent<Shove>` a message, and the sweep
/// refuses any that no resolver claims. It is a `Choice` as well, so a
/// hog's brain can decide it and the engine routes the decision to the
/// same intent the player's key writes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Shove(Direction);
impl Action for Shove {}
impl Choice for Shove {
fn name(&self) -> &'static str {
"shove"
}
}
/// A shove that landed, for the log to read.
#[derive(Message, Debug, Clone, Copy)]
struct Shoved {
target: Entity,
}
Action is an empty marker trait and Intent<Shove> is its own message type.
There is no enum Action for it to be added to.
.add_choice::<Shove>()
.add_message::<Shoved>()
.add_systems(Turn, resolve_shoves.in_set(ResolveSet::Act))
add_action registers the message and installs a sweeper in TurnSet::Sweep, after everything that might have resolved the intent.
Warren says add_choice, which does everything add_action does and one thing more, for the hog at the end of this chapter.
Forget the resolver and you get a warning naming the type, not a frozen game with the player holding a turn nothing will spend.
What a resolver owes
/// What the shove costs. A shove is quicker than a swing.
const SHOVE_COST: u32 = BASE_ACTION_COST / 2;
/// Everything the resolver moves.
#[derive(bevy::ecs::system::SystemParam)]
struct Shoving<'w, 's> {
occupancy: ResMut<'w, Occupancy>,
map: Res<'w, WorldMap>,
holders: Query<'w, 's, &'static Position, With<MyTurn>>,
targets: Query<'w, 's, (&'static mut Position, Option<&'static mut Viewshed>), Without<MyTurn>>,
}
/// Resolves a shove.
///
/// The shape every resolver has: claim the turn so nothing else spends it,
/// do the thing, and say how it went. `done` charges what it cost; `failed`
/// leaves the player holding the turn and charges anyone else, so a
/// monster cannot try the same impossible shove forever.
fn resolve_shoves(mut intents: MessageReader<Intent<Shove>>, mut resolution: Resolution, mut shoved: MessageWriter<Shoved>, mut world: Shoving) {
for intent in intents.read() {
if !resolution.claim(intent.actor) {
continue;
}
let Ok(from) = world.holders.get(intent.actor) else {
resolution.failed(intent.actor, SHOVE_COST);
continue;
};
let offset = intent.action.0.offset();
let behind = from.0 + offset + offset;
let room = world.map.is_walkable(behind) && !world.occupancy.is_occupied(behind);
let pushed = world.occupancy.first_at(from.0 + offset).filter(|_| room).and_then(|t| world.targets.get_mut(t).ok().map(|found| (t, found)));
let Some((target, (mut pos, viewshed))) = pushed else {
// Nobody there, or nowhere for them to go: nothing happens, and
// the turn is still the player's to spend on something else.
resolution.failed(intent.actor, SHOVE_COST);
continue;
};
world.occupancy.relocate(target, pos.0, behind);
pos.0 = behind;
if let Some(mut v) = viewshed {
v.dirty = true;
}
shoved.write(Shoved { target });
resolution.done(intent.actor, SHOVE_COST);
}
}
Resolution is the engine’s side of every resolver, its own and yours.
Claim the turn. resolution.claim returns false if the actor holds no turn, so the intent is stale, or if something already spent this one. One turn is one action, across resolvers that have never heard of each other.
Say how it went. resolution.done charges what the action cost and requeues the actor. resolution.failed is for one that could not be done: the player keeps the turn at no cost, and anyone else is charged, because a monster handed a free retry asks again forever. That rule lives in Resolution, so no resolver has to remember it.
Keep the indexes straight. Moving something means occupancy.relocate as well as writing Position, and marking a moved viewshed dirty.
SHOVE_COST is BASE_ACTION_COST / 2: fifty against a step’s hundred, both scaled by Speed.
Reporting through a Shoved message instead of logging from inside the resolver keeps narration out of it and lets anything else react later.
A monster that shoves
The hog shoves too, and it takes three lines more than the player did.
/// The hog's move: shove whoever stands beside it instead of biting. A
/// tactic of Warren's own, in the brain beside the engine's, that decides
/// Warren's own action.
struct ShoveAdjacent;
impl Tactic<Entity> for ShoveAdjacent {
fn name(&self) -> &'static str {
"shove_adjacent"
}
fn evaluate(&self, ctx: &mut TacticCtx<'_, Entity>) -> Option<Decision<Entity>> {
let me = ctx.snapshot.me.pos;
let foe = ctx.snapshot.adjacent_enemies().next()?;
Direction::between(me, foe.pos).map(|d| Decision::own(Shove(d)))
}
}
A tactic is a type that reads the snapshot and answers with a Decision.
The engine’s decisions are its own actions, a step, a blow, a use; yours is Decision::own(Shove(d)), a box holding your type.
Shove implements Choice as well as Action, one name for the trace, and the brain puts ShoveAdjacent in front of MeleeAdjacent for any rat whose file says shoves: true.
.add_choice::<Shove>()
add_choice replaces add_action.
It registers the action and its sweeper as before, and it routes every choice of that type a mind makes to the same Intent<Shove> the key writes, so resolve_shoves never learns whether a hog or a hand asked.
A choice nobody routed is refused by the sweeper, not lost.
What a tactic needs to know that the engine does not, a scent or a post to return to, you push onto the snapshot in PerceiveSet::Annotate with add_sense and read back in the tactic with sense::<T>().
A game that decides a monster’s whole turn itself claims it with acting.claim_decision in TurnSet::Decide, and the engine’s brains leave that monster alone.
Try it
- Make a shove fail against a heavier monster with
resolution.failed. - Charge a full turn instead of half. The clock is the balance knob.
- Delete
resolve_shovesand press the key. Read the warning. - Give the rat king
shoves: trueand stand beside it.
Next: panels.
Where to go next
Warren is a complete roguelike in about 450 lines and uses maybe a third of the engine. The Systems pages are the reference for the rest, one per system, and each teaser below links to the page that covers it.
Lighting
- Add one resource:
commands.insert_resource(Lighting::dark()); - You supply a
LightSourceon a prop, an actor or an item,Fuelif it burns down, andDarkSighton whoever sees without one. - You get the
Viewshedsplit from chapter 2 starting to matter:linestays geometric,visibleshrinks to what is lit, and a monster that sheds nothing is found only where a light reaches it.FuelreportsLightEvent::BurntOut. - Worked examples
heist, where wall lamps are the only light, snuffing one is how you cross a room, and the watch light them again.delvebelow the Maw: a brand that can be smothered, a torch to set down, andvto see the light as digits. - Reference Sight and lighting.
- Design
docs/design/lighting.md.
Stealth
- Add
StealthPlugin. - You supply
Noticeon an observer, which is a certain radius, a chance beyond it, a bonus while the subject stands in light, and a memory;Stealthon a subject narrows both. - You get a roll between being seen and being noticed.
A monster that has not noticed you does not act on you, one that loses you searches where it last saw you, and
Watchersanswers who is watching whom for the panels. - Worked examples
heist: a thief the watch have to notice, a pebble that draws them to the wrong corner, and a shout that brings the rest.delveis built around it and Corsair’s caves use it. - Reference Stealth.
- Design
docs/design/stealth.md.
Abilities
- Add
AbilitiesPlugin. - You supply abilities as data: an aim, a shape from the targeting footprints, costs, requirements, a cooldown and a list of named effects.
The engine ships seven effects and a game registers its own with
add_effect. - You get a key that writes
AimAt, a cursor the engine opens, a preview of what the shot would cover, and the turn spent. An item thatGrantsan ability lends it to whoever carries it, and aChargecost is spent from the item, so a potion is a line of RON. - Worked examples
delvehas five,corsairfour, andcrates/rl-bevy/tests/genres.rsloads five genres of them into one registry. - Reference Abilities.
- Design
docs/design/abilities.md.
Statuses, stats and gear
- Add
StatusPlugin, and put the registries a game needs inRegistries. - You supply what wearing a thing does, on the thing: a worn blade carries the
MeleeAttackit is swung with, a coat itsArmor, and an affix what itBestowson a stat. - You get afflictions ticked by the turn through the damage pipeline, with stacking rules and cures; a stat block with a modifier accumulator; an equipment slot graph with displacement; and an affix model of prefixes and suffixes with level-scaled grants, weighted rolling and per-instance state.
Loadoutsums the gear at the blow andfold_gearkeeps the stats current, so nothing is ever copied onto the wearer. - Worked example
corsairuses all of it. - Reference Statuses for the afflictions and the stat block, and Items and equipment for the slots, the affixes and the
Loadout.
Quests
- Add
FactsPlugin. - You supply facts with a kind, a subject, an object and an amount, and objectives over them.
- You get
Questswith prerequisite chains and a victory flag, andCounters, a ledger of named tallies. It is the grown-up version of chapter 6’sif the king died. - Reference Statuses, where
FactsPluginsits beside the statuses it shares a page with.
Saving
- Add
SavePluginand a backend resource: files, memory or browser storage, all behind one trait. - You supply
Saveableon the component that marks each kind of thing your game spawns, saying how to write one down and spawn it again, registered withsave_kind. - You get the rest of the walk: where each thing stands, its health, its bag, its slots and its statuses, along with the scheduler’s queue, the world’s edits and places, and what has been explored.
The envelope is versioned and refuses a mismatch instead of guessing, entities are remapped on the way back in, and
SavePluginkeeps the save a turn behind the run so a closed window saves. It forgets the save when the run ends. - Worked example Corsair’s
save.rs: four kinds and four resources, in about four hundred lines. - Reference Saving and the ending screen.
The run’s beginning and end
- Add nothing.
CorePluginhas it. - You supply a start system in
NewRun, and whatever your game keeps of a run that the engine does not, forgotten inEndRun. - You get the engine running your start again after every
Restart, on a fresh seed or the same one, with the old run torn down first.RunOverends a run, from the player’s death unlessCombatRulessay otherwise, or from any condition of your own.GameMenuPanelopens over the ending and offers the next run, under whatever your game pushed ontoEndingViewabout what the run came to. - Reference The turn loop for the schedules, and Saving and the ending screen for what is kept and what is shown at the end.
A narrator
- Add
NarratorPlugin. - You supply nothing, or a
Phrasebookwith the phrases you would rather it used. - You get every engine event spoken, split by who did what to whom, with names in the colours of the things they name.
Reword a phrase, silence one, or read the
NarrationViewand say it your own way. A line of your own told from inside a turn is aTell, written inTurnSet::React, and the narrator speaks it after what it answers and before whatever the next actor does. - Reference Narration.
A world above the dungeon
- Add
StreamingPlugin, andrl-overworldfor a map screen. - You supply the world’s parameters.
- You get FBM noise, elevation banding, priority-flood hydrology, climate, scored site placement and a road router, streamed around the player with a seam hash so chunk edges agree, keeping your edits across unload and reload.
rl-overworlddraws it with a portal picker. - Worked example
corsair. - Reference Places and streaming for the two kinds of map and the streaming, and The overworld for the screen over them.
Balance
rl-rules::balance scores threat and prints a band report over the BandedTable from chapter 7.
cargo run -p corsair -- --balance
Panels, past the two you have
Warren uses a status strip and a log, added in chapter 3 when there was something to put in them. The engine has four more views and the machinery behind all of them.
A panel is split in three, and the split is why any of it belongs in an engine.
The view is a resource of plain data, rows and bars and numbers, with no colour and no string the game did not supply.
The collector refills it in ViewSet::Collect.
The presenter draws one, in a PresentSet layer, taking its rectangle in its constructor.
“Every actor in the viewshed, nearest first, with a health fraction and a relation” is the same sentence in every roguelike; a gold-ruled rail with small-caps headings is one game’s taste.
That gives five places to stop, and you can stop at any of them: add the panel and be done, change the Palette and restyle everything at once, push a Facet for what the engine cannot know, keep the view and draw it yourself, or add neither.
- What the engine cannot know is a
Facet: a key, some words and a tone, pushed onto a row inViewSet::Annotate. Warren already does this for the crusts in your bag and the floor you are on. - Colours are never passed to a widget. Every widget takes a
ToneId, a semantic role thePaletteturns into a colour, andadd_tonedeclares a role and colours it in one call. - Screens are a stack of interned ids in
Modals, withno_modalandmodal_isas run conditions, so one gate on your input covers every screen you ever add. - Keys can be declared once in a
Controlsregistry and read back by name, which is what letsControlsPanelshow exactly the keys the game reads. - Two presenters over one view:
LogPaneldraws the last few lines along the bottom andScrollbackPaneldraws all of them on a screen, over the same log, and neither knows the other exists. - The forecast in the look cursor is not the panel’s arithmetic.
rl_rules::forecastruns the average roll through the same mitigation pipeline a real blow goes through, so it cannot drift from the fight.
examples/tutorial/src/bin/step10_panels.rs is the worked example, with the rail, the look cursor, tones and a controls screen, and docs/design/ui.md is why it is shaped that way.
Panels is the reference for the split and for every view the engine ships, and Controls, modals and cursors for the declaration, the stack and the cursor.
Testing without a window
The engine ships the test kit it uses itself, in rl_engine::rl_bevy::testing, and a game’s tests use the same copy.
A headless app is MinimalPlugins, states and CorePlugin, plus the engine plugins your game uses and your own systems, exactly as main does minus the three that draw.
Two update calls start a run and deal the player its first turn; after that it is one per action.
Writing an intent is how the game plays itself, and a suite like Warren’s runs in about forty milliseconds.
KeyScriptPluginandpress(&mut app, key)play a key the way a keyboard does, so a test drives your real input system instead of writing intents by hand.surface(&mut app)stands an open test world up for a test that needs a map and not the game’s own.two_sides(&mut app)inserts combat rules for two sides at war.
Where a property exists, assert it over a range of seeds: that every generated floor has somewhere to stand and somewhere to go is forty-eight floors of evidence that costs milliseconds, because generation is tier 1 and never builds an App.
Where no property exists, use a fingerprint test and say so in its name, so a change reads as a change rather than a failure.
Test the refusal as well as the action: a wrong refusal crashes nothing, it silently eats a turn or freezes the loop.
The test module of step10_panels.rs is the worked example.
The crates
| Tier | Crates | Bevy |
|---|---|---|
| 0 | rl-core | no |
| 1 | rl-grid, rl-mapgen, rl-world, rl-rules | no |
| 2 | rl-bevy, rl-render, rl-ui, rl-overworld, rl-save | yes |
| 3 | rl-engine | facade |
Map generation, field of view, pathfinding, the damage pipeline and the AI brains are tier 1. They run headless, test in milliseconds and build for WebAssembly, so the tests in chapter 9 cost milliseconds, and CI enforces the boundary. A tool that needs only one of them can depend on that crate alone and never compile Bevy.
The examples
| Example | What it shows |
|---|---|
delve | Five floors of a beached whale, no surface at all; lighting, stealth and five knacks; floors.rs is the whole map builder |
corsair | An open-world pirate roguelike with a pirate’s abilities, built only on the public API |
heist | Three floors of a counting house in the dark; stealth and light end to end, with a score to carry out |
foundry | Three decks of a droid foundry; combat depth, with guns that run hot or dry, droids that shoot back, and a probe that shouts for them |
Reading further
docs/OVERVIEW.md, the inventory of what exists and what does not.docs/PLAN.md, why, decision by decision.docs/design/, one file per subsystem: how it works and why it is shaped that way. Nine of them, and the five this chapter has not sent you to already are fields (fire and gas), minds (how a non-player decides), noise (what a sound is and who hears it), props (crates, levers, containers and traps) and remains (what is left where something died).docs/TODO.md, the work that has been found and not started, if you would rather build the engine than a game on it.AGENTS.md, the rules the build enforces and the rules review enforces.
The turn loop
A turn is dealt to one actor at a time, from a queue keyed by an integer clock.
One pass of the Turn schedule deals that turn, lets a mind decide what to do with it, resolves the decision, and puts the actor back at the reading it is next due at.
The loop runs the pass again and again inside one frame until the player holds a turn or nothing moved, to a ceiling of 512 passes, so a player’s step costs one frame however many monsters are awake between.
What a turn caused is answered inside the same pass, and what is worth watching can stop the loop until it has been seen.
Turning it on
CorePlugin is the loop, and it is the one plugin every game adds.
It creates the Turn, NewRun and EndRun schedules, chains EngineSet across Update and TurnSet across a pass, and puts run_turns in EngineSet::Turns.
It registers the actions that need nothing else: Step, Wait, Bump, Swap, GoThrough, Open and Close.
It sets Turn to run on one thread, because a pass is dozens of small systems dealt once per actor turn rather than once per frame, and the multi-threaded executor’s per-system handoff cost more than the systems it was handing off.
It declares needs::<WorldMap>, so a game that never builds a map is told so, by name, the moment play begins rather than by an empty screen.
A game’s own action is registered with app.add_action::<A>(), which adds Intent<A> as a message and one sweeper that refuses an intent no resolver claimed.
Cues and the hold are in CorePlugin too, and stay inert until a plugin that draws them calls TurnHold::watch.
A landing is not: add_airborne is called by AbilitiesPlugin, CombatPlugin and ThrowingPlugin, so an Airborne<L> arrives with the subsystem that flies something rather than with the loop.
The model
Turns wraps a TurnQueue<Entity> whose clock is a u32 in hundredths of a step, with BASE_ACTION_COST at 100.
Entries come out earliest first, ties settling by insertion order, and Entry is ordered on those two fields alone so no entity’s bit pattern can decide who goes first.
scaled_cost(base, speed_percent) divides in integers, reschedule_at saturates rather than wraps, and set_now panics on a clock asked to run backwards.
An actor holding MyTurn is out of the queue until something reports ActionDone or ActionRefused for it.
schedule deals to the first live actor that is due and standing where play is; one on another map or outside the loaded window is requeued a full step without acting, and the clock advances at most once a pass, writing TurnEnd when it crosses into a new whole turn.
admit_new_actors holds a freshly spawned actor out of the queue until it first stands where play is, and admits the player ahead of the rest so a first turn does not depend on archetype order.
A decision is an Intent<A> for an A: Action, written by the game in EngineSet::Input for the player and by minds in TurnSet::Decide for everyone else.
A resolver takes a Resolution: claim gives it the turn once, done(actor, cost) spends it, and failed(actor, cost) refuses for the player and charges anyone else, because a monster handed a free retry asks again forever.
cleanup_turns requeues at scaled_cost of what was owed against Speed, requeues one actor once per pass, and charges a wait to any non-player left holding a turn nobody used.
Cued is what a resolver writes when a turn did something worth seeing: a Cue::Flight between two Anchors or a Cue::Burst on several, where an anchor that follows an entity goes where the entity goes.
TurnHold is the brake, and it takes only while something watches: hold_for_cues raises it after any pass that cued, and run_turns then runs no pass until the watcher releases it.
Airborne<L> is what a subsystem has in the air; launched hands the landing straight back when nothing watches, so a headless game lands everything at once.
Using it
The tutorial’s input system is the whole of asking to act: a key becomes an Intent, and the engine does the rest.
/// The player, but only while it is holding the turn.
type PlayerTurn<'w, 's> = Query<'w, 's, Entity, (With<Player>, With<MyTurn>)>;
/// Keys to intents. Writing an intent is the whole of asking to act: the
/// engine claims the turn, charges it, and refuses what cannot be done.
fn player_input(
keys: Res<ButtonInput<KeyCode>>,
dirs: Res<DirectionKeys>,
repeats: Res<Repeats>,
player: PlayerTurn,
mut steps: MessageWriter<Intent<Step>>,
mut waits: MessageWriter<Intent<Wait>>,
mut exit: MessageWriter<AppExit>,
) {
if keys.just_pressed(KeyCode::KeyQ) {
exit.write(AppExit::Success);
return;
}
// No turn in hand means it is somebody else's move; the key is dropped.
let Ok(entity) = player.single() else { return };
// A press walks, and a key held down keeps walking: `Repeats` is the
// engine's hold, already advanced before input is read.
if let Some(dir) = dirs.just_pressed(&keys).or_else(|| repeats.firing_any().map(|(d, _)| d)) {
steps.write(Intent::new(entity, Step(dir)));
} else if keys.just_pressed(KeyCode::Period) || keys.just_pressed(KeyCode::Numpad5) {
waits.write(Intent::new(entity, Wait));
}
}
The line
Costs and clocks are integers in hundredths of a step, the same unit everywhere, so an f32 never gets the chance to lose a low bit and reorder two actors who were tied.
What an action costs is the game’s number; what happens to the clock once that number is known is the engine’s.
Anything that reacts to what a turn caused belongs in TurnSet::React, inside the pass and before the next actor acts, not in the drawing phase: a drink that heals, a bite that poisons, the loot the dead leave.
A system that scans the world every frame is not a reaction and belongs in PresentSet::Narrate, because React runs once a pass and one frame may hold hundreds.
Systems in a pass run several times a frame, so a system that must run once a frame says so by being in EngineSet::Input or a PresentSet layer instead.
Which executor a pass runs on is the engine’s default and not its decision: a game whose own system in the pass is heavy enough to be worth a thread puts the multi-threaded one back with app.edit_schedule(Turn, ..).
The engine owns who is dealt a turn, when, and what is done with an action that nobody resolved; the game owns what actions exist beyond the few above, what each costs, and who is allowed to try it.
A game orders its systems into TurnSet and ResolveSet, never after another crate’s system function.
Where it lives
rl-core is tier 0 and has no Bevy in it: TurnQueue is generic over the actor id, so its whole ordering contract is tested with ids made out of thin air, and scaled_cost and reschedule_at are const fn tested by arithmetic alone.
That split is why the ordering rules can be proved without an App, and why nothing in them can quietly come to depend on an entity’s index.
rl-bevy is tier 2 and owns the loop over it: turn.rs has Turns, Occupancy, Action, Intent, Resolution and the systems of a pass, and cue.rs has Cued, TurnHold and Airborne.
plugin.rs has the sets, the schedules, run_turns and CorePlugin itself, in one file because the order of a frame is one decision and not six.
Registries and content
A registry is every definition of one kind, held in file order and addressable by a dense typed id or by name.
Registries is the one resource holding the eight the engine’s own subsystems read: damage kinds, factions, stats, statuses, tags, slots, gases and props.
A game fills them before play begins, and loads its own content against them, so a name in a file becomes an id once, at startup, and nothing compares strings during play.
The engine never learns what any of those ids mean.
Turning it on
There is no plugin here, and no system.
Registries is a resource the game inserts, and a plugin that reads one says so with needs::<Registries> and a hint naming which registry it wants filled.
StatusPlugin, GasPlugin, FirePlugin, AbilitiesPlugin, PropsPlugin and three of rl-ui’s view plugins all declare it, and every one that is missing is reported together when play begins.
A registry a game has no use for stays empty, and empty means none: no statuses means no badges on a health bar, no slots means nothing is worn.
Registries name each other, so a game fills them in the order they refer to one another, the ones that name nothing first and the ones loaded through Registries::names after.
The model
Registry<T> holds a Vec<T> and an Interner<T>, and issues ids densely in the order definitions arrive, so a Vec indexed by id is a valid per-definition table.
from_defs takes them in order, from_ron_str parses a RON list, and push adds one; each refuses a duplicate name with ContentError::Duplicate.
get(id) indexes straight into the definitions, so it panics only when the id is past the end: an id of the same type from a different registry is in range and gives the wrong definition rather than failing.
try_get answers Option instead, id(name) answers Option, and expect(name) panics, for names the game knows it shipped.
validate runs a check over every definition and collects every failure, so a file with three typos reports three.
Named is the one thing a definition type implements: it answers with its unique name, and that is the whole of what the engine asks of a game’s own type.
Names borrows whichever registries exist, the engine’s and a game’s alike, and Names::load reads a file through it: a NameRef<T> field in the RON becomes an Id<T> at load, and a name looked up in a registry that was never given is reported as unknown rather than panicking.
Names::load_list reads a plain list the same way, for rows that name content without being content: a spawn table has a row per band for one monster, so it is not a Registry, and its errors are reported by row.
Registries::names builds that view over seven of its own fields, every one but props, which is itself loaded through it, so a game loads its content against exactly the tables the engine will read it with.
The fields of Registries are public and ordinary, so filling one is assignment and there is no builder to learn.
Nothing here is a plugin, a system or a schedule; a registry is data a game hands over before EngineState::Playing.
Using it
The tutorial’s start fills the two registries combat reads, loads its own bestiary against them, and hands the resource to the engine.
/// Hands the engine the map rules and the player, then warps the player in.
fn start(
mut commands: Commands,
seed: Res<Seed>,
mut warps: MessageWriter<WarpRequest>,
mut log: ResMut<MessageLog>,
mut next: ResMut<NextState<EngineState>>,
) {
let warren = Warren::new(seed.0);
// The two registries combat reads: what damage can be, and who hates
// whom. Both are the game's content, named nowhere in the engine.
let kinds = Registry::from_defs(vec![DamageKind::new("bite"), DamageKind::new("venom"), DamageKind::new("kick")]).unwrap();
let sides = Registry::from_defs(vec![FactionDef::new("you"), FactionDef::new("vermin")]).unwrap();
let (you, vermin) = (sides.expect("you"), sides.expect("vermin"));
commands.insert_resource(CombatRules::new(&sides).hostile(you, vermin));
let registries = Registries { damage_kinds: kinds.clone(), factions: sides, ..default() };
// What a hit passes through on its way to the target. One stage here;
// resistances, a shield, a critical rule would each be another.
commands.insert_resource(DamageStages(vec![Box::new(SubtractArmor)]));
// The bestiary names the damage each creature deals, so it loads against
// the registries before they are handed to the engine.
commands.insert_resource(Bestiary::load(®istries.names(), vermin));
commands.insert_resource(registries);
commands.insert_resource(warren.appearance());
commands.insert_resource(WorldMap::new(warren.tiles.tables()));
commands.insert_resource(PlaceRulesRes(Box::new(warren)));
let player = commands
.spawn((
(Actor, Player, Blocks, Position(Point::ZERO)),
(Viewshed::new(9), RevealsMap, Faction(you), Glyph::new('@', Color::WHITE).on_layer(10)),
(Health::full(24), Armor(1), MeleeAttack::new(kinds.expect("kick"), DiceRoll::new(1, 6))),
(Inventory::default(),),
))
.id();
warps.write(WarpRequest::into_place(player, map_of(1)));
log.push(format!("Seed {}. You squeeze into the warren.", seed.0.0), Tones::NOTICE, 0);
next.set(EngineState::Playing);
}
The line
Content is an id in a registry.
There is no closed taxonomy enum for content anywhere in the engine: no DamageKind variant list, no enum Faction, no set of statuses a game picks from, because a list the engine ships is a list a game forks to add its third entry.
#[non_exhaustive] with a Custom { id } variant is explicitly not how this engine extends, since it keeps the shipped names privileged and makes everyone else’s content a special case at every match.
The two extension mechanisms are a registry and a trait, and that is all of them.
The engine decides that ids are dense, that duplicates are refused, and that an unknown name fails at load with the name in the message; the game decides what definitions exist, what fields they carry, and what any of it means.
Registries holds the registries the engine’s own subsystems read, and a game’s own kinds go in a resource of its own, loaded through the same Names so a cross-reference between the two resolves.
A registry is filled once, before play, and read from then on: nothing in the engine adds a definition during a run.
Where it lives
rl-rules is tier 1 and has no Bevy in it: Registry, Named, ContentError, Names and BandedTable are there, so loading a content file, refusing a duplicate and resolving a NameRef are all tested with plain values and no App.
rl-core is tier 0 and owns Id<T> and the Interner the dense ids come from, which is why an id is typed and cannot be handed to the wrong registry without the compiler saying so.
rl-bevy adds exactly one thing on top: registries.rs, which is the Registries resource and the names view over it, and nothing else.
That the Bevy layer’s whole contribution is a struct of eight public fields is the point: what a game loads and how it validates belong to a tier that can be tested without a frame.
Seeds and determinism
A run has one seed, and everything random in it is derived from that seed by name.
A game inserts a Seed and nothing else: each subsystem that rolls owns a generator under a domain of its own and derives it for itself, so adding a subsystem adds no line to a game and one subsystem’s draws never shift another’s.
The same seed and the same build give the same run, which is what makes a replay a replay and a failing test a bug rather than a mood.
Determinism is promised within one build of a game, not across engine versions.
Turning it on
There is no plugin here either.
Seed(RunSeed(n)) is a resource the game inserts before play begins, and Seed::from_args is the reading of it off wasm, in three steps.
The seed of the recording RL_REPLAY names comes first, whatever the command line says, because a replay is the run it was recorded from and a replay on a fresh seed drifts on its first key; then --seed N from the command line, refusing a value that is not a whole number by name; then a fresh seed.
A plugin that rolls calls app.add_stream::<S>("PluginName") in its own build, which declares needs::<Seed> with that plugin named, so a game that forgot the seed is told which subsystem wanted it.
CombatPlugin, AbilitiesPlugin, MindsPlugin, PropsPlugin and StealthPlugin each do this, and two plugins asking for the same stream get one deriving system between them.
A game that adds none of those still inserts a Seed if anything of its own draws, because Seed::stream is a method on the resource.
The model
RunSeed(pub u64) is the root, and derive(domain, index) is mix64(mix64(seed ^ domain.salt()) + index), a const fn depending on all three inputs, so a new run reshuffles every index of every domain at once and two domains never share a stream.
SeedDomain::new(b"name") is FNV-1a over the name with the low bit forced on, and it is a struct rather than an enum, so a game declares const WEATHER: SeedDomain = SeedDomain::new(b"weather") without editing the engine.
The name is what keys a stream, so renaming a domain rerolls it and reordering declarations does not.
RunSeed::rng is StdRng::seed_from_u64 over that derivation; StdRng and not SmallRng, because the algorithm has to be the same in a browser as on the desktop.
Seed(pub RunSeed) is the Bevy resource, and Seed::stream(domain, index) is RunSeed::rng with the seed already in hand: that is where a game’s own draws come from.
Stream is the trait a subsystem’s generator implements, with one method, for_run(seed) -> Self, which is where the domain name is written down.
add_stream::<S> inserts a marker so a second registration adds nothing, and adds derive_stream::<S>, which runs on resource_exists_and_changed::<Seed> before EngineSet::Stream, not gated on play.
So a stream is derived before the first frame that needs it, and derived again the moment the seed changes, which is how a game that learns its seed late, from a save it is continuing, gets streams that match the run it is resuming.
RunSeed::fresh mixes the wall clock with a per-process counter, and is not compiled on wasm, which has no SystemTime; from_entropy takes a reading the host supplies instead.
For a draw that must not depend on visiting order there are position_hash and pair_hash, which depend only on their inputs, so iterating cells in a different order cannot reroll them.
Using it
The tutorial’s spawner takes a stream keyed by its own name and the map it is filling.
/// Fills the floor the one time it is built. `PlaceEntered::first` is
/// true only on that arrival, so coming back does not restock it.
fn populate(mut commands: Commands, mut entered: MessageReader<PlaceEntered>, rats: Res<Rats>, map: Res<WorldMap>, seed: Res<Seed>) {
for ev in entered.read() {
if !ev.first {
continue;
}
let Some(place) = map.place(ev.map) else { continue };
let bounds = place.terrain.bounds();
// A stream of its own, keyed by name: adding another spawner later
// cannot shift the numbers this one draws.
let mut rng = seed.stream(b"warren.rats", ev.map.0 as u64);
let mut placed = 0;
while placed < 16 {
let p = Point::new(rng.random_range(bounds.x..bounds.right()), rng.random_range(bounds.y..bounds.bottom()));
// Not on top of the player, and not close enough to be unfair.
if !map.is_walkable(p) || geometry::chebyshev(p, ev.entry) < 8 {
continue;
}
commands.spawn((
(Actor, Blocks, Position(p), Speed(110), Faction(rats.faction)),
(Health::full(6), Armor(0), Perception(7), DarkSight(9), Mind(rats.mind.clone()), Glyph::new('r', Color::srgb(0.72, 0.55, 0.45)).on_layer(5)),
(MeleeAttack::new(rats.bite, DiceRoll::new(1, 3)), Name::new("rat")),
));
placed += 1;
}
}
}
The line
A subsystem’s stream is a Stream registered with add_stream, derived from the run’s Seed through RunSeed::derive(domain, index), and never anything else.
A game’s own draws come from Seed::stream, named and indexed the same way, which is why a spawner added in the tenth week cannot shift what a spawner written in the first one places.
No engine crate builds a generator from a constant, and none seeds one straight from entropy: every generator comes from a RunSeed, and RunSeed::fresh is the one function in the engine that turns a clock into a seed, off wasm only, for a run nobody named a seed for.
A function that rolls takes &mut impl Rng rather than making one, so the caller decides which stream it came from and a test can hand it a fixed one.
The engine decides how a domain and an index become a generator and when a stream is rebuilt; the game decides what its domains are called, what they are indexed by, and where its seed came from.
Choosing an index is the game’s judgement and it matters: keying by map id gives a floor the same contents whether or not the player dawdled on the one above, and keying by a counter does not.
A replay is a seed plus a build, so a change to a draw order in engine code is a change to every recorded run, which is why determinism is promised within a build and not across versions.
Where it lives
rl-core is tier 0: RunSeed, SeedDomain, mix64, position_hash and pair_hash are all const fn over plain integers, so what a derivation gives for a seed and a domain is proved by arithmetic with no App and no World anywhere near it.
That is also why the same derivation runs on wasm32-unknown-unknown, where rl-core has to build and where there is no clock to fall back on.
rl-bevy adds only the wiring: seed.rs is the Seed resource, the Stream trait, add_stream and the one system that derives, which is under two hundred lines including its tests.
Keeping the arithmetic a tier below the wiring is what lets a seeding property be tested over a range of seeds rather than over a range of frames.
Grids and tiles
Everything in the engine that has a place is at a Point, on a grid addressed row-major, with y growing downward.
A tile is two bytes of identity, and everything the engine knows about one is a record in a registry the game filled before play.
An algorithm never asks what a tile is; it asks a grid one of two questions, does this cell block sight and what does it cost to enter, and both answers come from that registry through parallel tables of flags.
This is the vocabulary every other system’s page is written in.
Turning it on
There is no plugin here and no system, because there is nothing to run: rl-core and rl-grid are tier 1 and below, with no Bevy in them at all.
A game turns them on by building a TileRegistry, calling tables() on it once, and handing the TileTables to whatever holds the map.
TileRegistry::standard is the conventional starting set, void, wall, floor, door_closed and door_open, with void at id 0 so an unwritten cell is solid rather than an open field; TileRegistry::new is an empty one for a game that wants none of those names.
A registration is refused with RegisterError::Duplicate if the name is taken, and tables() panics naming the tile if one opens, closes or burns into a name nobody registered.
Names are resolved there rather than at registration, which is what lets a door be registered before the tile it opens into.
The model
Point is two i32s ordered on y and then x, so a sorted list of points reads top to bottom and left to right, and several generation passes lean on that to break ties the same way every run.
Rect is half-open on the right and bottom, and carries the predicates a layout pass wants: contains, is_border, intersects, too_close with a margin, inflate, intersection, union, and cells, interior and border as row-major iterators.
Direction is the eight compass points declared clockwise from north, with index pinned to that order so anything stored per direction is laid out the same way, and DirectionSet packs a subset into one byte: what a cell records is not whether a road is here but which ways it leaves by.
Grid2D is the addressing contract every map-shaped type implements: width and height are all an implementor writes, and bounds, xy_idx, idx_point, in_bounds, checked_idx and neighbours come with it, so an algorithm is written once against the trait and runs on a terrain, a scratch grid or a streamed window alike.
Steps picks four neighbours or eight, and Grid<T> is the dense row-major container every layer uses; a layer per grid rather than one grid of fat structs, so a sweep over one layer stays in cache.
geometry is the pure shape arithmetic over plain points: chebyshev, euclidean_sq, octile_milli as an integer heuristic scaled by a thousand, line by Bresenham, and square, disc and cone as iterators so a caller that only tests membership allocates nothing.
TileId is a u16 index; TileProps is the record behind it, carrying walkable, passable, opaque, blocks_projectiles, move_cost, opens_to, closes_to and an optional Burn.
walkable against passable is the load-bearing pair: a closed door is not walkable this turn but a corridor through it still connects two rooms, and a connectivity pass that confused the two would wall off half a map.
TileTables is what the hot loops actually read, a Vec per flag indexed by id, with opens and closes resolved to ids and burn resolved to a Kindling.
Terrain is a Grid<TileId> and nothing else, and Terrain::view(®istry) makes a TerrainView that answers the two questions from the tables.
Those two questions are the traits OpacitySource and CostSource, both supertraits of Grid2D, and both answer for an out-of-bounds cell without being asked: off the grid is opaque and impassable, so a scan stops at the edge rather than checking twice.
A newtype over a view that overrides one method is how smoke, a hazard or a swimmer’s costs go on top without copying the terrain, and the doc-test on TerrainView is that pattern written out.
BitGrid is one bit per cell for viewsheds, visited sets and explored maps; SpatialGrid<E> is a BTreeMap<Point, Vec<E>> for who is standing where, kept out of the terrain so moving every turn does not mark the terrain changed.
AStar is one mover to one goal, holding its scratch buffers across searches, and returns a Path of steps and a total cost; DijkstraMap is one flood any number of movers descend, bounded to a Rect so a continuous world never floods a million cells a turn, with UNREACHED for what it did not reach and signed values so scale and rescan invert it into the safety map a fleeing monster follows.
PathRules says whether diagonals are allowed and whether one may cut a corner between two blocked orthogonals, off by default, and a diagonal costs 1414 to an orthogonal’s 1000.
region::flood marks what is reachable into a BitGrid and region::label_regions numbers every connected component at once, both taking passability as a closure over flat indices so they run on anything.
targeting::footprint resolves a TargetMode, own cell, adjacent, bolt, ball, beam or cone, into the Footprint of cells it covers and the path a projectile took, with blocking passed in as two closures because what blocks is the caller’s to say: what stops a projectile, walls and whoever stands in the way, and what stops a burst, walls alone.
targeting::burst is the burst on its own, every cell in the radius a straight line from the centre reaches without crossing a wall, which a ball bursts with where it lands and a thrown thing’s trigger with where it comes down; a ball that hits a wall bursts on the near side of it.
Using it
A game’s tile layer is a registry filled by name and a table of its own keyed by the same ids: the tutorial registers a wall and a floor with the flags left at their defaults, and keeps the colours they are drawn in beside the registry rather than in the props.
/// The warren's tiles, and how each one looks in full light.
struct Warren {
tiles: TileRegistry,
seed: RunSeed,
}
impl Warren {
fn new(seed: RunSeed) -> Self {
let mut tiles = TileRegistry::new();
tiles.register(TileProps::wall("earth")).unwrap();
tiles.register(TileProps::floor("dirt")).unwrap();
Self { tiles, seed }
}
/// Both colours of every tile, and how much each cell jitters from
/// its neighbours. The renderer derives darkness and memory from these.
fn appearance(&self) -> TileAppearance {
let mut look = TileAppearance::new();
let t = |name| self.tiles.expect(name);
look.set_varied(t("earth"), Cell::new('#', Color::srgb(0.78, 0.66, 0.50)).on(Color::srgb(0.34, 0.27, 0.21)), Vary::new(0.20, 0.05));
look.set_varied(t("dirt"), Cell::new('.', Color::srgb(0.66, 0.58, 0.45)).on(Color::srgb(0.18, 0.15, 0.12)), Vary::new(0.28, 0.06));
look
}
}
The line
Nothing on a gameplay or generation path is keyed by a HashMap or a HashSet.
SpatialGrid is a BTreeMap, TileRegistry looks names up through a BTreeMap, a viewshed is a BitGrid and a per-id table is a Vec, and the reason is the same one every time: a hash container iterates in an order that depends on the hasher, so a run would branch on something no seed controls and a fingerprint test would fail for no reason anyone could find.
The ordered container is also the faster one at these sizes, so the rule costs nothing to keep.
Costs are integers in hundredths of a step, the same unit as the turn clock, so a path’s cost is the time it takes and NORMAL_MOVE_COST is 100.
octile_milli is scaled by a thousand rather than being a float for the same reason: two runs must never disagree over a last-bit compare.
The engine decides how a grid is addressed, what order neighbours come back in, which ring of cells nearest_from searches first, and how a tie in a path or a flood is broken; all of it is pinned by tests because a whole town’s layout hangs off it.
The game decides what tiles exist, what each is called, which flags it carries, what it costs, what it opens into and how it burns.
The engine ships no tile enum, and there is nothing in it that matches on a tile: a game that wants ice, vacuum or a force field registers one and it is first-class from the first frame.
Anything only the game cares about, a glyph, a colour, a line of flavour text, belongs in a game-side table keyed by the same id, not in TileProps.
Where it lives
rl-core is tier 0: points, rectangles, directions, the Grid2D contract and the geometry, with no dependency on anything above it and none on Bevy, so the row-major arithmetic and the shape iterators are tested by value and proved by property over a range of inputs rather than by looking at a screenshot.
rl-grid is tier 1 and adds what runs over a grid: tiles, terrain, the two source traits, the bit grid, the spatial index, the two pathfinders, regions and targeting shapes.
Both build for wasm32-unknown-unknown, which scripts/check-tiers.sh --wasm checks, so nothing in them may reach for std::time::Instant.
The split that matters is the one between Terrain and TileRegistry: identity in the grid, meaning in the registry, joined only by a TerrainView.
That is what lets the same terrain be read under a swimmer’s costs, a smoke overlay or a viewer’s knowledge at the same time, and what keeps a pathfinder from ever needing to know what a door is.
Map generation
Every generated thing in the engine, a dungeon floor or a chunk of open world, is a chain of named passes run over a context. A pass reads and writes the context’s terrain, draws from a stream keyed by its own name, and either does its work or says why it could not. The chain checks at assembly that its passes go forward through the phases and that no two share a name, and panics on either, because both are mistakes in how the chain was written rather than conditions to survive. What a pass has to tell the rest of generation, a room it carved, a start it chose, a vault it stamped, it publishes as a typed output the later passes and the caller read back.
Turning it on
There is no plugin and no system: rl-mapgen is tier 1, has no Bevy in it, and nothing in it runs unless a game calls Chain::run.
A game builds a BaseContext over a blank terrain, assembles a Chain with then, and runs it with a RunSeed.
Where that happens is the game’s business, and in practice it is inside the PlaceRules::build the engine calls the first time a place is entered, or inside the ChunkRules::chain the streamer calls for a region.
Chain::run stops at the first failure and hands back the BuildError naming the pass, so a caller that asked for six rooms on a map with space for four retries rather than shipping a map its game does not expect.
A retry builds a fresh BaseContext and runs the chain again with a different seed or a different index: a pass that fails may already have written part of its work, as Rooms does, so the context it failed in is not a clean slate to try again in.
The model
Pass<C> is three methods: name, which must be stable because it keys the pass’s stream, phase, which says when it runs, and apply, which does the work or returns a BuildError.
Phase is six variants in pipeline order: Ground, Growth, Structures, Connect, Exits, Finish.
Chain::then asserts the new pass’s phase is not before the last one’s and that its name is fresh, so a chain that would have run its doors before its rooms fails where it was written instead of in a screenshot.
Chain::run installs seed.rng(SeedDomain::new(name), 0) on the context before each apply, then takes a snapshot, and names and len let a caller report what a chain is.
BuildContext is the boundary an engine pass sees: terrain and terrain_mut, tiles, rng and set_rng, emit and outputs, and a take_snapshot that defaults to doing nothing.
Every engine pass is generic over C: BuildContext, which is the whole extension mechanism: a game wraps BaseContext in a struct carrying its own fields, implements the trait by delegation, and the engine’s passes still run over it unchanged.
rl-world’s ChunkContext is the engine’s own instance of that, a BaseContext with a region’s neighbourhood, per-tile heights and per-tile facts alongside it.
BaseContext is a terrain, a TileRegistry, a stream, Outputs and optional snapshots; with_snapshots records the terrain after every pass, for a tool that wants to watch a map being built, and finish takes the terrain and the outputs apart.
Outputs is typed rather than keyed: emit pushes any Any + Send value, and iter::<T>, first::<T> and take::<T> read back only the ones of that type, so two passes publishing different things never collide.
passes holds what needs nothing but a terrain and a registry: Fill and Border in Ground, Scatter and ScatterBy in Growth, CellularCave in Ground, KeepLargestRegion in Connect, and CentralStart in Exits.
Scatter and ScatterBy carry their own name field because two scatters in one chain must draw different streams, and ScatterBy takes a ChanceFn<C> so a chance can follow moisture or slope rather than being one number.
dungeon holds the bounded-map passes: Rooms and Bsp in Structures, Doors behind them, and RandomStart and FarthestExit in Exits.
Rooms scatters non-overlapping rectangles and joins each to the one before it with an L-shaped corridor, failing below min_rooms; Bsp splits the map into a tree of leaves, carves a room per leaf and joins siblings, so the layout fills the map evenly.
Both publish a Room per room in placement order, which is what lets Doors find the ring around each one and a prefab ask for a room to sit in.
RandomStart and CentralStart publish a StartPoint, and FarthestExit walks from it and publishes the cell furthest away as an ExitPoint.
prefab is the hand-drawn half: a Prefab is rows of characters and a legend, a character the legend does not know is transparent so a piece can be an irregular shape, and rotated and flipped carry a piece’s marks around with its tiles.
StampPrefab places one at a Placement with an Orient saying how it may be turned first, authored per stamp because the same vault may turn freely in a cave and be fixed against the corridor its door has to meet.
StampOneOf is the same pass with a weighted choice in front of it; a zero weight is a piece in the list that is never drawn, which is how a piece stays in while it is being worked on, and nothing carrying weight fails the chain.
Either stamp publishes a Stamped with its bounds and its marks in map coordinates, so a later pass can keep out of it or spawn into it.
Using it
A game’s generation is a chain assembled for the map being built, run with a seed of that map’s own.
/// How a floor is built. The engine calls this once, the first time
/// something enters the map, and keeps what comes back.
impl PlaceRules for Warren {
fn build(&self, map: MapId, _: Option<&WorldGraph>) -> Result<PlaceBuild, BuildError> {
let depth = floor_of(map);
let (wall, open, roots) = (self.tiles.expect("earth"), self.tiles.expect("dirt"), self.tiles.expect("roots"));
let mut ctx = BaseContext::blank(84, 42, self.tiles.clone(), wall);
// A stream per floor, so the second floor is the same whether or
// not you dawdled on the first.
let seed = RunSeed(self.seed.0 ^ (depth as u64) << 32);
let chain = match depth {
// Dug rooms near the surface.
1 => Chain::new().then(dungeon::Rooms { floor: open, attempts: 40, min_size: 5, max_size: 10, min_rooms: 6 }).then(dungeon::Doors { door: roots }),
// Gnawed-out caves under them.
_ => Chain::new().then(passes::CellularCave { wall, floor: open, fill_pct: 45, ..Default::default() }).then(passes::KeepLargestRegion { wall }),
};
// Every floor gets a start and a point as far from it as the floor
// allows: the stairs down on the first, the way out on the second.
chain.then(dungeon::RandomStart).then(dungeon::FarthestExit).run(&mut ctx, seed)?;
PlaceBuild::from_context(ctx)
}
}
The line
Nothing in generation is seeded from a constant or from entropy, once a chain is running: a fresh BaseContext carries a placeholder generator seeded from zero, and Chain::run replaces it with the pass’s own before any apply, so only a pass applied by hand outside a chain would draw from it.
A pass’s stream is RunSeed::derive of the run’s seed against a SeedDomain made from the pass’s name, so what a pass draws depends on the seed and its name and on nothing else.
That is what inserting, removing and reordering buy: adding a decoration pass in the tenth week cannot shift a room the room pass placed in the first, so a chain is edited without every earlier seed becoming a different map.
It is also what renaming costs, since a renamed pass is a new stream and rerolls, and the assertion against two passes sharing a name exists because two that did would draw an identical sequence and lay the same pattern twice.
The seed a chain is run with is the caller’s to choose, and the choice matters as much as the passes: a map keyed by its own id is the same map whether or not the player dawdled on the one above, and a map keyed by a counter is not.
A pass that rolls takes the stream from the context rather than making one, and a helper that rolls takes &mut impl Rng, so a test hands it a fixed generator and reads the arithmetic.
The engine decides the pipeline: what a phase is and what order the six come in, that a name keys a stream, that a failure stops the chain and names itself, and how each shipped pass does its work.
The game decides which passes are in the chain, which tile ids each one writes, what the numbers are, what the context carries beyond a terrain, and what the outputs mean.
No shipped pass knows what a wall or a cave or a road is; every tile it writes arrives as a parameter, which is why the same CellularCave carves a warren, a mine and a nest of tunnels in three different games.
Where it lives
rl-mapgen is tier 1 with no Bevy in it and nothing Bevy-shaped either: a chain is a value, a context is a struct, and running one is a function call.
That is why a whole floor can be generated in a test in microseconds, with no App and no frame, and why the properties that matter are checked as properties: that a chain with a pass added draws the same values for the passes around it, that a different seed or a different name rolls differently, and that a failing pass stops the chain, names itself, and leaves every pass behind it unrun.
The crate builds for wasm32-unknown-unknown like the rest of its tier, so nothing in generation may time itself.
The split from rl-grid is the one to keep in mind: rl-grid owns the terrain, the tiles and the flood fill, and rl-mapgen owns only the order things happen in and the streams they happen from.
Places and streaming
A game’s maps are of two kinds, and everything that reads tiles reads both through one resource. A place is bounded, built once by the game’s rules the first time something enters it, and kept whole from then on, actors and items included. The surface is streamed: a window of regions around the player, generated on demand, dropped when it leaves the window, and remembered only as the edits made to it. Every entity with a position is on exactly one map, and the turn loop freezes whatever is not on the one being played.
Turning it on
Places need no plugin: CorePlugin puts resolve_warps in ResolveSet::Travel and tag_new_positions in TurnSet::Schedule before anyone is dealt a turn, and declares needs::<WorldMap>, so a game that builds no map is told so by name when play begins.
What a game adds is a PlaceRulesRes wrapping its PlaceRules, and the engine calls it the first time a map id is entered.
StreamingPlugin is the surface, and it is the part a game leaves out: it adds stream_chunks in EngineSet::Stream and declares needs::<WorldRes> and needs::<ChunkRulesRes>, each with a hint naming what to build one from.
EngineSet::Stream runs first in the frame so the window is loaded around wherever the player ended the previous one before anybody is dealt a turn on it.
A game of floors and nothing above them adds neither: with no WorldRes nothing streams, WorldMap::new takes the tile tables and nothing else, and how big a region is never enters its vocabulary.
WorldSettings is how much stays loaded, a window_radius of 1 giving the 3x3 default.
The model
MapId is a u32, MapId::SURFACE is zero, and a game numbers its places however it likes above that.
OnMap is the component saying which; it is missing until something first gains a position, and tag_new_positions fills it in with the current map.
WorldMap is the resource every reader reads: tile, set_tile, is_walkable, is_opaque, blocks_projectiles, cost, opens, closes and is_loaded all answer for the current map, whichever kind it is, and None or opaque-and-impassable for a cell that is not loaded.
current, window, window_tiles, to_world and to_local say which map and which window; view and opening_view hand a WindowView to a grid algorithm, the second costing a closed door as the turn spent opening it plus the step onto what it opens into, so a flood routes through a door when that is shorter.
Three counters tell readers when to recompute without anyone asking them: generation moves when the window moves or the map changes, opacity_epoch when an edit changes what blocks sight, and cost_epoch when one changes where an actor may walk or what a step costs.
set_veil is the fourth case, a set of cells that hide what is behind them for a reason other than their tile, rewritten each turn by whatever makes it and lifted whenever the map or the window under it changes.
PlaceRules::build is the one method a game writes, taking a MapId and the WorldGraph when there is one, and returning a PlaceBuild: a Terrain, an entry, an optional exit, and a Vec<Spot> of points of interest tagged in the game’s own numbering.
PlaceBuild::from_context is the usual way to make one from a finished chain, taking the chain’s StartPoint as the entry, its ExitPoint as the exit, and every prefab mark as a Spot tagged with its character.
Transition is a component on an entity standing on a cell, holding the Destination it leads to: a cell of the surface, or a place with an Arrive of its entry, its exit or a named cell.
GoThrough is the action that takes the player through the one it is standing on, and WarpRequest does the same from anywhere for a portal or a first arrival, resolved without charging a turn so the game charges what it likes.
Only the player travels; anyone else asking to go through fails like any other impossible action.
A warp that lands on a map nobody has built calls the rules, installs the result with install_place, and writes PlaceEntered with first true, which is the one arrival on which a game populates a floor; MapChanged is written on every change of map.
The warp also switches the per-map indexes with the map: Occupancy swaps its SpatialGrid for the arriving map’s and stashes the one it had, Knowledge does the same with explored tiles, and FlowFields is invalidated so the next mind rebuilds.
On the streaming side, stream_chunks asks desired_window for the square of radius window_radius around the player’s region clipped to the world, and does nothing if that is the window already or if the player is in a place.
Loading generates each new region through WorldGraph::build_chunk and the game’s ChunkRules, replays that region’s stored delta onto it, keeps every chunk still in the window, and files the edits of the ones that left.
ChunkLoaded is written once per region generated, and every viewshed is marked stale when the window moves.
export and import are the save shape: every surface edit, loaded or not, and every built place whole, with loaded chunks dropped on import so the next stream replays the edits onto fresh generation.
Using it
A game reaches a place by spawning a Transition on a cell, and where those cells are is the game’s to decide: corsair puts a cave mouth in every cove the moment the region holding it streams in.
/// Regions whose cave mouth has been placed.
#[derive(Resource, Default)]
pub struct Entrances(pub std::collections::BTreeSet<Point>);
/// Puts a cave mouth in the middle of each cove the first time it streams in.
pub fn mark_entrances(mut commands: Commands, mut loaded: MessageReader<ChunkLoaded>, mut done: ResMut<Entrances>, world: Res<WorldRes>) {
for ev in loaded.read() {
let Some(site) = world.site_index_at(ev.region) else { continue };
if world.sites()[site].kind != COVE || !done.0.insert(ev.region) {
continue;
}
let at = world.region_tiles(ev.region).center();
commands.spawn((Stairway, Position(at), Transition { to: Destination::Place { map: cave_id(site, 0), arrive: Arrive::Entry } }, stair_glyph('>')));
}
}
The line
The engine decides when a place is built, which is the first time anything enters it, and that it is never built twice.
It decides that a place is kept whole once built, so leaving one freezes what is in it and returning finds it as it was, and that the surface is not: a region outside the window is thrown away and only its edits survive.
It decides that a chunk is a pure function of the run seed and its region, through WorldGraph::chunk_seed, so a region regenerates identically however many times it has been walked across, which is what makes storing a delta instead of a map correct rather than merely cheap.
It decides that only the player travels, that an actor on another map or outside the loaded window is frozen and requeued a full step without acting, and that such an actor can neither act nor be seen.
The game decides what a map id means, what is built there, where the transitions stand and what they lead to, what an arrival costs, and everything that goes in a place on the arrival PlaceEntered marks as its first.
The game also decides whether there is a surface at all, and that decision is made by adding StreamingPlugin or not rather than by any resource being present or absent.
Which regions are in the window is the engine’s; what a region generates into is the game’s ChunkRules, and the seam where two chunks meet is arithmetic both sides compute from the same unordered pair of regions rather than a negotiation either could lose.
A game that wants a region populated the first time it is seen listens for ChunkLoaded and remembers which it has done, because the engine will load a region again after it has been unloaded and will not tell it apart from the first time.
Where it lives
rl-bevy is tier 2 and owns both halves, because both are about what the running game is reading right now, which is not a question a lower tier can answer.
places.rs is the vocabulary and the warp; world.rs is WorldMap, the window, the chunk store and StreamingPlugin.
The part that can be tested without an App was pushed below: rl-world is tier 1 and owns the world graph, the chunk builder and the seam arithmetic, so that two neighbouring chunks agreeing at their shared edge is a property checked over a range of region pairs by calling build_chunk twice, with no frame anywhere.
What is left above the line is genuinely frame-shaped: which map is current, which window is loaded, and which indexes have to be swapped when that changes.
Keeping the surface’s whole existence optional is the other thing the split buys, since a game of floors alone links rl-world without ever constructing a WorldGraph, and nothing in its vocabulary mentions a region.
Props
A prop is what stands on a map that is neither an actor nor an item: a crate, a plate, a console, a barrel, the wreck a machine leaves. What one is comes to a cell, a name and a look. Everything else, standing in the way, holding things, offering things, going off underfoot, hiding, breaking, is a component the definition asks for and a game may leave out. What any of it means is the game’s, and the whole of how a game says so is one message.
Turning it on
PropsPlugin declares needs::<Registries> with props loaded from a props.ron, since a prop is a definition in a registry the way a monster is, and depends_on::<CorePlugin>.
It adds offer_here in DecideSet::Offer, beside the ability gate; report_entered at the end of ResolveSet::Travel, after every step, swap and warp; resolve_interactions and resolve_takes in ResolveSet::Act; spot_hidden_props in DecideSet::Notice, beside the stealth roll it resembles; perceive_props in PerceiveSet::Annotate; arm_props in TurnSet::Schedule; and close_emptied_containers and report_destroyed in TurnSet::React.
Outside the turn loop it configures PropSet::Stock then PropSet::Fill inside EngineSet::Stream, because a prop is put down while a place is built: the engine builds every prop’s effects, arms each new prop and asks for what each container holds in the first, and a game answers in the second, so what goes into a crate lands in the frame the crate was put down rather than in that frame or the next depending on which way the executor ran two systems.
report_bare_props runs in PostUpdate and in no set at all, since Added matches for one frame and a report gated on play having begun is a report that never happens.
It takes PropRng for what a container holds and what is spotted, and adds EffectsPlugin if the game has not, which lands a trigger’s effects from EffectRng, because a trap lands the same effects an ability does and wants the same dice.
It declares reads for every message its work may touch that belongs to a plugin a game may have left out, so a game may have props with no items, no combat and no statuses, and those queues stay empty.
CorePlugin registers Intent<Interact> whether or not props are on, because the bump redirect writes one and a writer for an unregistered message panics; the resolver and the sweep stay here, which is what decides whether props run.
The model
PropDef is what a props.ron says: a name, a Look of glyph, colour and layer, blocks, an optional health, a list of OfferDef, a list of triggers, and optional container and hidden.
load resolves every name in the file and reports every problem in it at once rather than the first.
spawn_prop(commands, registries, id, at, map) puts one down and returns the entity, so a game can hang its own components on it; the definition says what, the game says where.
It inserts Prop, PropKind(id), a Name, a Position and an OnMap, and then Blocks, Health, Container with an Inventory, and Hidden as the definition asks.
No glyph: a look travels as data and whoever draws dresses the prop from the same definition, exactly as a tile is described once as a tile and once as a look.
OfferDef is a verb, a time in hundredths of a step, an optional needs tag and a list of effects.
offer_here works out, for the actor holding the turn and nobody else, what it is offered by what it stands on and what stands beside it, as an Offer { prop, verb, time, refused }.
A refused offer is still listed, with Refused::Needs(tag) saying which tag it wants, so a screen greys the row and says why rather than hiding it.
A container’s lock is folded in here, since opening a locked thing wants its key as surely as an offer that asked for one itself, and an Emptied container stops offering open.
Interact { prop, verb } is the one action whatever the verb: its resolver spends the offer’s time, lands whatever effects the offer carried, and writes Interacted { actor, prop, verb }.
Verbs are interned by Verbs, OPEN and SEARCH first and the rest through app.add_verb, so two games spell open the same way.
Container is Inventory on something that is not an actor, so everything that reads a bag reads this one.
stock_containers rolls each one’s counts from PropRng and asks the game with FillContainer { prop, item, count }, keyed on a Stocked marker rather than on Added because a stream derived from the run’s seed may not exist on the frame a place is built.
Answering one means spawning that many of what it names, wherever the game spawns things, and pushing the entities onto the prop’s own Inventory; nothing checks that the answer arrived, so a container nobody filled is an empty container and no error.
Take { from, item } moves one or all into the taker’s bag, merging stacks and writing ItemEvent::PickedUp the way the ground does, and costs one turn either way.
close_emptied_containers marks a container Emptied only when its definition gives an opened look, so a crate that cannot show it is done goes on offering and the screen says it is empty.
Each trigger is the TriggerSpec an item’s are, an on moment by name, an Area, an optional fires count and effects, and a prop answers two of the engine’s moments, entered and destroyed.
build_prop_effects builds every trigger and every offer once on the first frame, after the last game-registered effect and moment are in, and reports every spec that would not build with the prop’s name and which list it was in, because a trap that silently does nothing is the worst kind of trap.
arm_props puts its kind’s Triggers and LandsAsItself on each new prop, so a trap strikes as itself and not as whoever stepped on it, the lists shared and the count of fires left the prop’s own, since a definition is shared by every plate of its kind; PendingFires carries a restored prop’s count until it is armed.
report_entered reads Stepped, which the move resolver writes for every step it lets through, so anything that walks sets off a plate, and it lands in the same pass, before damage.
report_destroyed reads DeathEvent, which a prop with Health raises like anything else, and since the prop is gone by the end of the frame it leaves a Remnant carrying its triggers and its name, reported and landed a pass later; a prop with nothing to do when broken leaves none.
Both only report Fired { on, moment, by, at }, the second through the remnant; Effects lands the list, rolling each chance and reading each argument exactly as a spell does, over the one cell or the burst the trigger names, and a game reads the same message for what no effect can say.
Hidden { spot } is a prop nobody has spotted: the map view and a mind’s contributor both skip one, which is a query filter rather than a second drawing path, and spot_hidden_props rolls spot percent a turn for the player alone while it is in sight, writing Spotted and taking the component off.
perceive_props puts what the mind holding the turn can see into Snapshot::props as a PropView of which entity, where, and whose side, so a mind that walks to wrecks and a mind that walks to crates read one list.
Using it
The engine owns the interaction and the game owns what it meant, which is a system reading Interacted and keeping the verb it registered.
/// Answers the `charge` verb: reports the fact the tracker counts, says
/// so, and leaves the console reading as spent.
///
/// The engine has already decided the charge was possible, spent the
/// three turns the offer asked for, and landed whatever effects the offer
/// carried, which for this one is none. What is left is the part no
/// effect could express: a fact about this run. The console then becomes
/// a different kind of prop, one with no offer and a duller glyph, which
/// is how it stops being something to charge twice.
pub fn answer_charge(
mut commands: Commands,
mut done: MessageReader<Interacted>,
verbs: Res<Verbs>,
registries: Res<Registries>,
mut report: ChargeReport,
consoles: Query<(&PropKind, Option<&OnMap>)>,
) {
let Some(charge) = verbs.get(CHARGE) else { return };
let Some(spent) = registries.props.id("spent reactor console") else { return };
for ev in done.read() {
if ev.verb != charge {
continue;
}
let Ok((_, on)) = consoles.get(ev.prop) else { continue };
let deck = crate::decks::deck_of(on.map(|m| m.0).unwrap_or(MapId::SURFACE));
// The glyph comes off with the kind, so the renderer dresses it
// again from the definition of a console already used.
commands.entity(ev.prop).remove::<Glyph>().insert(PropKind(spent));
report.happened.write(Happened(Fact::new(report.facts.charge_set).about(u64::from(deck))));
report.tell.write(Tell::new("You set the charge. The reactor stirs.", Tones::GOOD));
}
}
The line
The engine decides whether an interaction is possible, what it costs and when it resolves; what it meant is the game’s, read off Interacted and filtered by verb.
There is no registry of behaviours and no trait to implement, because a game already knows how to answer a message, and a refusal is free: the offers are worked out before the player acts, so nothing is spent finding out that nothing was there.
A prop that blocks and offers one thing it can take up is walked into, because the bump redirect writes the interaction; one offering two, or offering one it refuses, spends no turn and says so as Bumped, since a walk key can neither ask a question nor give a reason.
What a bump cannot reach, a body underfoot or a plate already found, is InteractKey’s, which takes the one offer in reach and takes none where two are offered.
OffersPanel and ContainerPanel, in rl-ui, are where that question and that rummaging are drawn, and a game with neither gets nothing rather than a guess.
A prop that blocks sight is not here: field of view reads the map’s tiles and the veil gas writes into, so a sight-blocking prop is a second mechanism and the veil is where it goes on the day something needs it.
Doors stay tiles, because a door has no state of its own to remember and props are for things that remember something.
A prop with no Name is reported once and loudly: nothing can list it or look at it, which is a spawn bug every time and never a choice.
Saving is the engine’s, and the first save kind that is: SavePlugin registers PropKind itself, so a game that saves gets an emptied crate still empty, a sprung trap still sprung and a spotted plate still spotted, without writing a line for it.
Where a prop stands and what a container holds are saved as any entity’s are; what is written down beyond that is which definition it is, by name, and the part that is this prop’s own history rather than its kind’s.
Where it lives
rl-rules is tier 1 and has no Bevy in it: prop.rs is the content half and nothing in it runs, so a props.ron with three mistakes names three without an App anywhere.
Two things are left as names there rather than resolved to ids, and both for the same reason: a verb is a string until there is a run to intern it in, and a container’s contents are item names because items are a game’s own registry.
rl-bevy is tier 2 and owns everything that happens: props.rs is the plugin, the components, the gate, the two actions, the reports of its two moments and the spotting roll, and it is one file because a prop’s parts are one idea seen from several sides.
What a trigger does is not in it: landing one is effects’, shared with every item, so a prop’s trap and a thrown grenade cannot drift apart.
What stays out of it says as much: the look is dressed in rl-render from the same definition, the offers screen and the take-only modal are in rl-ui, and the save kind is in rl-save, because Saveable is that crate’s and rl-bevy sits below it.
Effects
An effect is one thing that happens to a cell or to whoever stands in it: harm, a mend, a status, a shove, a fire, a cloud of gas.
Three kinds of thing land them, an ability an actor knows, a prop in the world and an item in a bag, and this is the subsystem all three use as peers.
It owns what an effect is, how a list of them is built, rolled and described, the one stream they roll from, and the moments at which a prop or an item does what it does.
What an effect costs the thing that carried it is not here; that is ConsumablesPlugin’s, which reads the same messages.
Turning it on
EffectsPlugin is added by any plugin that lands effects, AbilitiesPlugin, PropsPlugin and ConsumablesPlugin, when the game has not added it, so a game rarely names it and the order a game lists its plugins in never matters.
It initializes EffectKinds and Moments, takes the EffectRng stream, registers Fired and the DamageEvent, Afflict and Cure every landing may write, reads Cued, and runs report_remnants then land_triggers in ResolveSet::Triggers.
It is not unique and builds once, so a game that adds it by hand as well gets one copy and no error.
That stage sits between ResolveSet::Act and ResolveSet::Fields: after every action that reports a moment, and before fire, gas, statuses and damage, so a grenade’s fire spreads and a stim’s mend is applied in the pass that set them off.
add_engine_effects() registers the seven effects that need no subsystem, and FirePlugin and GasPlugin register Ignite and Emit, so a content file naming either loads exactly when the game has fire or gas.
A game registers an effect of its own with app.add_effect::<E>() and a moment of its own with app.add_moment(name), both while the app is built.
The model
Effect is a type with apply(&self, &Landing, &mut EffectWorld) and a describe a menu reads, and FromArgs builds one from the text arguments a content file gave it.
EffectKinds files each under its KIND, and Effects::build turns a list of EffectSpec into built effects or reports every spec that would not build.
Landing is what an effect sees: the user, what landed it as a Source of Ability, Trigger { on, moment } or Offer, the origin and aim, every cell covered and every actor under them.
EffectWorld asks the subsystem that owns a thing to do it, damage, a status on or off, a cue, and moves an actor itself, since nothing else owns that.
Effects::land rolls each entry against its own chance from EffectRng, which keeps the derivation domain b"ability" it had as AbilityRng, so a trap, a stim and a spell are dealt from one deck and every seed rolls what it rolled before.
Moments interns the names of moments, the engine’s use, land, fire, hit, entered and destroyed first so their ids are constants on the type.
TriggerSpec, in rl-rules beside EffectSpec, is the authored form: on, a moment by name; area, Here or Burst { radius }; fires, how many times before it stops; effects, a list of its own; and look, what shows over the cells it lands on.
Triggers::build(specs, shared, moments, kinds, names) builds a definition’s triggers once, sharing each list through an Arc, and fails on a moment nobody registered, naming it.
A spec with no list takes the definition’s shared one, and a spec with neither fails, since a trigger that does nothing is a typo.
Triggers is the component, and each copy counts its own fires, so springing one cable spends nothing of another.
Fired { on, moment, by, at } is how a subsystem reports a moment, and all it does: items report use on an accepted use, throwing reports land where a throw comes to rest, combat reports fire for each attack made with a worn thing and hit at the struck actor’s cell, and props report entered and destroyed.
land_triggers reads each Fired in the order written, takes the entity’s triggers for that moment in list order, skips any spent, cues a burst over the cells for one with a look, lands the list over area_cells, the one cell or the burst rl_grid::burst works out inside the loaded window, with everyone in the area as a target, the one who set it off included, and counts fires down.
The landing’s user, who a hit is credited to, is by, or the carrier itself when it is LandsAsItself, which every armed prop is.
A Remnant is a carrier that lands its triggers once and is despawned, for something gone by the time they land: it holds its moment, by and at as data, and report_remnants writes its Fired in the pass that lands it, so a pass held back while something is shown loses nothing.
A broken prop leaves one for its destroyed moment, and a watched shot from a thing its last charge spent leaves one for its hit.
Using it
A trigger is a moment, an area and a list, and Foundry’s grenades are four of them, each a burst where the grenade comes down.
// The grenades: thrown, and what each does is its land trigger, a
// burst where it comes to rest, which spends it there. Plate takes half
// a frag burst off a droid; an ion burst undoes a chassis and blinds
// every radar in it, and barely touches flesh.
(name: "frag grenade", glyph: '*', color: (0.7, 0.72, 0.45), stack: true, throw: (range: 6), consumable: (charges: 1, when_empty: Destroyed),
triggers: [(on: "land", area: Burst(radius: 1), look: (glyph: '*', color: (r: 255, g: 200, b: 90)), effects: [(kind: "Harm", args: (kind: "kinetic", roll: "3d6"))])]),
(name: "smoke grenade", glyph: '*', color: (0.75, 0.75, 0.75), stack: true, throw: (range: 6), consumable: (charges: 1, when_empty: Destroyed),
triggers: [(on: "land", area: Burst(radius: 1), look: (glyph: '*', color: (r: 200, g: 200, b: 200)), effects: [(kind: "Emit", args: (gas: "smoke", amount: 160))])]),
(name: "ion grenade", glyph: '*', color: (0.4, 0.7, 1.0), stack: true, throw: (range: 6), consumable: (charges: 1, when_empty: Destroyed),
triggers: [(on: "land", area: Burst(radius: 2), look: (glyph: '*', color: (r: 90, g: 170, b: 255)), effects: [(kind: "Harm", args: (kind: "ion", roll: "1d4"))])]),
(name: "incendiary grenade", glyph: '*', color: (0.95, 0.45, 0.2), stack: true, throw: (range: 6), consumable: (charges: 1, when_empty: Destroyed),
triggers: [(on: "land", area: Burst(radius: 1), look: (glyph: '*', color: (r: 255, g: 110, b: 40)), effects: [(kind: "Harm", args: (kind: "thermal", roll: "2d4")), (kind: "Ignite", args: (turns: 4))])]),
A game builds its definitions’ triggers once when it reads the file, against the moments and kinds the run registered, so a typo is a startup failure and not a grenade that lands nothing.
// Each definition's triggers, built once against the moments and
// effect kinds the run registered, and every problem in the file
// named at once: a grenade that lands nothing is a typo.
let mut triggers = Vec::new();
let mut errors = Vec::new();
for (_, d) in defs.iter() {
triggers.push(match Triggers::build(&d.triggers, &d.effects, moments, kinds, &names) {
Ok(t) => t,
Err(mine) => {
errors.extend(mine.into_iter().map(|e| format!("{}: {e}", d.name)));
Triggers::default()
}
});
}
assert!(errors.is_empty(), "assets/items.ron: {}", errors.join("; "));
The line
The engine decides what an effect does to the world, when a moment’s triggers land, over which cells and with what dice; what a thing is, and which moments it answers, is the game’s content.
A subsystem that owns a moment reports it and does nothing else, so “find the list, build the area, land it” is written once and a game’s own moment lands exactly as an engine one does.
Moments are an open registry and not an enum, for the reason every other name in the engine is: a blaster that does something when it overheats is a line of the game’s, not a fork.
One list can be delivered several ways, which is the whole of the potion model: what a bottle holds is written once as the item’s effects, and a use trigger and a land trigger each deliver it to different people.
An item is aimed only by being thrown or fired, so there is no cursor here; that is throwing’s and combat’s, and an aimed use with a cursor of its own would be an ability by another name.
A burst stops at walls and not at whoever stands in it, and it is the call an ability’s Ball bursts with, so a grenade and a fireball of one radius reach the same cells and neither reaches round a wall.
A trap lands in the pass it was stepped on, and a prop’s destroyed trigger a pass after the blow, since a death is known only after damage.
A trap’s harm is the trap’s own and not the doing of whoever stepped on it, so the log never says they hurt themselves; who stepped on it is still on the report.
Charges are not here: a game with triggers and no costs adds no ConsumablesPlugin, and spend_charges reads Fired after land_triggers so what the last charge did lands before the thing is gone.
Only what changes in play is saved, a trigger’s fires left beside a consumable’s charges, and the lists are rebuilt from the definitions.
Where it lives
rl-rules is tier 1 and has no Bevy in it: EffectSpec and TriggerSpec are the authored forms any game’s own file can deserialize, so a content file is checked for shape without an App.
rl-bevy is tier 2 and holds the rest in one module, because an effect needs the world to land: the machinery and the plugin, the engine’s nine effects beside it, and the triggers with the moments they answer.
The effects sit in their own module rather than each in the subsystem it asks, so combat, statuses, fire and gas never depend on the code that asks them, and abilities, props and items depend on effects rather than on each other.
That split is what let a prop’s trap stop reaching into abilities to land anything, and what lets a triggers test build a floor, report a moment by hand and read the damage without an ability, a prop or an item anywhere.
Abilities
An ability is the second thing an actor can spend a turn on, and the first is a blow.
It is that same sentence with every part named by data: a shape, what it wants under that shape, what it spends, what must be true of whoever uses it, what the turn costs, how long before it comes round again, and what lands.
A game writes them in a file and compiles nothing to add one.
What an ability does is a Rust type, because something has to know that a damage roll becomes a DamageEvent per actor in a footprint; everything else about it is a line of RON.
Turning it on
AbilitiesPlugin declares needs::<Abilities>, hinting that one comes from Abilities::load(ron, &EffectKinds, &names), and needs::<Registries> for the stats its costs and requirements name.
In finish it declares depends_on::<CombatPlugin>, since an ability’s damage goes down the pipeline a sword’s does.
It adds EffectsPlugin if the game has not, whose EffectRng is the stream every effect in the engine is rolled from whatever landed it, so writing one more ability cannot shift the combat stream and change every monster’s rolls in a run that was going fine.
It adds offer_abilities in DecideSet::Offer, perceive_abilities in PerceiveSet::Annotate, refresh_known in TurnSet::React, and land_abilities in LandSet::Ability chained ahead of resolve_abilities in ResolveSet::Act, because what is already in the air comes down before anything else is loosed.
EffectsPlugin registers Afflict, Cure and DamageEvent: EffectWorld writes all three, a writer for an unregistered message fails its system at startup, and a game with abilities should not have to add the status and combat plugins to find that out.
Every Actor is given an empty Known, Pools and Cooldowns as it is spawned, so an actor carrying nothing but Grants can use what it was granted.
The engine’s own effects are registered separately, by add_engine_effects() for the seven that need no subsystem and by FirePlugin and GasPlugin for the two that do, so an ability file naming Ignite or Emit loads exactly when the game has fire or gas.
ThrowingPlugin is its own opt-in and depends on items and combat both, because a throw is an item leaving a bag and a blow down the damage pipeline, and neither of those plugins has to know the other exists.
The model
AbilityDef is what an ability file says: a name, a description for a menu, an optional Look of glyph and colour, an Aim, a TargetMode with its range inside it, sight, requires, costs, time, cooldown and effects.
Every id in it is a name resolved through Names at load, so an ability calls a stat, a status, a tag, a slot or a damage kind by the name the rest of the content does, and a typo is a startup failure listing every one it found.
Aim is SelfOnly, Foe, Ally, Ground or Anyone, closed because it enumerates the questions the faction matrix can answer about a cell rather than a taxonomy of content.
It is what lets a mind fire an ability it cannot understand, through five predicates: Aim::wants says whether a cell holds the kind of thing it is after and the rest are built on it, hits says who a footprint catches, worth_aiming_at who is worth pointing it at, cycles_to who a player’s cursor stops on, and needs_cursor whether there is anywhere to point at all.
The user is its own ally whatever the matrix says, so a spray aimed at allies mends whoever sprays it and a burst on the ground burns whoever stands in it, while a foe-aimed shape never catches its user.
Cost is Pool, Health or Item, all or nothing, and closed for the same reason: each arm is something the engine already knows how to decrement.
A game that wants a sixth kind of fuel registers a stat and spends it with Pool, which is what mana, stamina, power, nerve, heat and powder all turn out to be.
Requirement is Has, Lacks, Wielding, InSlot or Above, each reading a table the engine owns, which is how a shield bash learns it needs a shield without the engine learning the word.
blocked(def, &Gates, &Purse, now, ready_at) returns every reason at once as a Vec<Blocked> rather than the first, so a row greyed in a menu says all of what is wrong with it, and Gates and Purse are borrowed views the caller fills from its own components.
A cooldown is an absolute time on the turn queue’s clock rather than a countdown, so a save that restores the clock restores every cooldown with it and nothing has to be ticked.
Use { ability, aim } is an Action like any other, aimed at a cell because most shapes land on ground; one whose aim needs no cursor is aimed at the user’s own feet.
resolve_abilities lands the aim first and then gates on the union of what the user cannot do and what the aim refuses, so one refusal carries both; then it pays, sets the cooldown and hands the ability’s Effects the Landing to run over, and reports AbilityEvent::Used or Refused.
A refusal costs the player nothing and keeps the turn, and costs anyone else the turn, which is what stops a monster retrying forever what it cannot pay for.
A Ball flies as a bolt and bursts where it lands with rl_grid::burst, which stops at walls and not at whoever stands in the way, and bursts on the near side of a wall it flew into.
Bystanders::land is the one answer to where a use goes, and aim_blocked the other half of the gate: whether an ability may be used at all against whether it may be used here.
The targeting cursor previews through that same call, so the cells it paints are the cells that will be hit, and a projectile stopped short of where it was pointed is Blocked::OutOfReach rather than a burst on a spot nobody chose.
Landing is the result: the user, the ability, the origin, the aim, every cell covered, the flight path, where a projectile stopped, and everyone under it the aim wanted there.
Its source says what landed it, Source::Ability, Trigger or Offer, so an effect can tell a spell from a trap without assuming either, and only an ability has a look to fly.
An Effect is a type with apply(&self, &Landing, &mut EffectWorld) and a describe a menu reads, and FromArgs is its constructor, kept separate so the trait a game writes stays object-safe.
EffectWorld asks for what another subsystem owns rather than doing it: damage, a status on, a status off, along with the effect stream, cues, and Commands for whatever the engine never thought of.
Asking is what keeps a fireball mitigated by the same armor a sword is, and moving an actor is the one exception, since no other subsystem owns it: position, sight_of, is_free, place and slide are methods on it, and slide is what keeps a shove out of a wall.
app.add_effect::<E>() files E under its KIND in EffectKinds, and an EffectSpec is the (kind, chance, args) every content file that lands effects is read for, its arguments left as text for whoever registered the kind and a chance above 100 refused at load as the typo it is wherever it appears.
Effects is a list of those built, and the one thing three carriers share: an ability an actor knows, an offer a prop makes, and the triggers a prop or an item carries, which Effects describes.
Effects::build builds every spec or reports every one that would not build, and Abilities::build runs it once per ability, keeps the results as a Vec<Effects> parallel to the ids and puts the ability’s name in front of each failure.
Effects::land rolls each entry against its own chance from EffectRng before applying it, so a trap and a stim are dealt from the same deck a spell is, and Effects::describe is the fold a menu prints: one line per effect that has something to say, with its chance in front when it is not certain.
The effects module holds Effects and nine effects: add_engine_effects() registers the seven that need no subsystem, Harm, Mend, Inflict, Cleanse, Shove, Pull and Teleport, and Ignite and Emit sit beside them to be registered by fire and gas instead; all nine live in that one module rather than each in the module it asks, so effects depend on combat, statuses, fire and gas and none of the four depends back.
Known is the set of abilities an actor knows, rebuilt every TurnSet::React from its own Grants and nothing else: a thing in the bag never lends an ability, because what an item does is its own triggers.
Offered is the turn-holder’s abilities sorted into usable and refused once a pass by the gate the resolver uses, read through usable_by and why_for, which answer only for the actor it was worked out for.
perceive_abilities copies usable into Snapshot::usable as Usable { ability, aim, mode }, everything the UseAbility tactic needs to score a footprint and nothing about what the ability does.
Throwing is the smaller half: Throwable { range, strike } is an item made to be thrown, Throw { item, at } its action, and flight the one answer to where it goes, shared with the cursor that previews it.
A thrown knife and a bolt stop at the same first wall or body, and each hangs in the air until whatever is watching has seen it fly.
Using it
A verb the engine does not ship is a type with two impls and one registration line, which is Corsair’s Plunder.
/// Shake a foe down: whatever is in its purse spills onto the ground at its
/// feet, to be picked up like any other loot.
///
/// A purse is Corsair's, not the engine's, so no engine effect could reach
/// it; this one reaches it through `commands`, which is the whole of the
/// escape hatch. It spills rather than pockets because a stack on the ground
/// merges into the bag through the engine's own pick-up, and an effect that
/// merged stacks itself would be a second copy of that rule.
#[derive(Debug, Clone, Copy, Default)]
pub struct Plunder;
impl Effect for Plunder {
fn describe(&self, _: &Registries) -> String {
"spills its purse at its feet".to_string()
}
fn apply(&self, landing: &Landing, world: &mut EffectWorld<'_, '_>) {
for target in landing.targets.clone() {
world.commands.queue(move |w: &mut World| {
let Some(coin) = w.get::<Purse>(target).map(|p| p.0).filter(|n| *n > 0) else { return };
let Some(at) = w.get::<Position>(target).map(|p| p.0) else { return };
w.entity_mut(target).insert(Purse(0));
w.resource_scope(|w: &mut World, armory: Mut<Armory>| {
let mut queue = bevy::ecs::world::CommandQueue::default();
let mut commands = Commands::new(&mut queue, w);
armory.spawn(&mut commands, armory.defs.expect("doubloon"), coin, Some(at));
queue.apply(w);
});
});
}
}
}
impl FromArgs for Plunder {
const KIND: &'static str = "Plunder";
fn from_args(_: &RawValue, _: &Names<'_>) -> Result<Self, String> {
Ok(Plunder)
}
}
The line
The engine decides whether an ability may be used, what it spends, where it lands and who is under it; what a verb it does not ship means is the game’s, written as an effect and registered by name.
There is no enum of effect kinds and no Custom { id }, so a game’s own effect sits beside the engine’s and the resolver cannot tell them apart.
The boundary is drawn at the verb and nowhere further in: making a damage roll into data too would mean shipping an expression language, an interpreter and a debugger for it, and the interesting half of every game would be written where there are no types and no stack traces.
An ability is content and its vocabulary is code, so a fireball, a smoke bomb or a rally is a RON edit, and Bribe or Hack is one file a game writes once and then never again.
Untyped arguments are what that buys, and validating them at load is what pays for them: a bad argument fails at startup naming the ability, not the first time somebody presses the key.
An ability is something an actor knows, so an item never lends one: a medkit or a grenade costs no ability id, takes no row on the screen beside what its carrier actually knows, and says that it is used up as a fact about the item rather than as a cost of an ability.
An item is aimed only by being thrown or fired, which throwing and combat already own, and there is no third kind of aimed use for abilities to take over.
A key aims nothing itself; it writes AimAt and stops, and whether a cursor opens, where it opens and what the use costs are the engine’s, which is why a game’s input never learns what a broadside does.
A mind is handed only what the gate already allowed, which is why it cannot loop on something it cannot afford, and it scores by Aim alone, so a monster given a new ability needs no new tactic.
Accuracy does not exist: an ability lands unconditionally, as every melee blow does, and a to-hit roll when it comes is a stage in the damage pipeline rather than a change here.
Ability trees, schools, levelling, spell failure and casting interrupted by a blow are each a game’s rule over this data, and the engine should not guess which of them a game wants.
Saving is the engine’s: EngineSave writes each actor’s pools and cooldowns, so a game that saves keeps a cooling ability cooling without writing a line for it.
What an ability was called on for is never asked; a use is an AbilityEvent, and a game reads one and counts whatever its run is about.
Where it lives
rl-rules is tier 1 and has no Bevy in it: ability.rs decides and never acts, answering whether a use is permitted over borrowed views of the user, so the whole gate is tested against a Gates and a Purse filled by hand with no App anywhere.
Aim’s five predicates live there too, which is what lets the resolver, the cursor’s preview and the scoring in tactics.rs share one rule rather than drifting four ways apart.
rl-bevy is tier 2 and owns everything that touches the world: ability.rs is the action, the resolver and the state a use spends, and the effects module is the seam every effect is registered through, Effects, and the nine the engine ships through that seam.
The seam sits there rather than in ability.rs because an ability is not the only thing that lands a list: what an ability, a prop and an item share is how a list is built, rolled and described, never when it lands or on whom, and that much was written three times before it was written once.
The effects sit in a module of their own rather than each in the subsystem it asks, because Harm in combat.rs would make combat depend on abilities to implement a trait, and the dependency is meant to run the other way.
throwing.rs is beside them rather than inside items or combat, for that same reason in two directions at once.
rl-ui owns the aiming and the menu and rl-save the save kind, so nothing below either has to know they exist.
Combat and loadout
A blow is an Attack on an entity: struck in reach with what the attacker wields, fired at range down a clear line of fire, and a spent turn when neither is possible.
What it rolls is never read off the attacker alone, because Loadout sums it at the moment it matters from the actor’s own components, every worn item’s, and the stats CombatRules names.
The roll becomes a Hit, the hit goes down a pipeline of stages the game composed, and what comes out the far end is taken off Health.
Above that line everything is the game’s: who hates whom, what a kind of damage is, what stops it, and what a death means beyond a body on the floor.
Turning it on
CombatPlugin declares needs::<CombatRules>, hinting that one comes from CombatRules::new(&sides), and needs::<Registries> for the damage kinds a blow can deal.
It takes CombatRng, a stream of its own derived from the run’s seed, so a game never inserts one and a weapon added late cannot shift the rolls of a run that was going fine.
It registers Attack as an action, DamageEvent, DamageDealt, DeathEvent and Struck as messages, ShotLanding as something that can be in the air, and DamageStages as a resource whose default is SubtractArmor alone.
Its systems are perceive_reach in PerceiveSet::Annotate, land_shots in LandSet::Shot chained ahead of resolve_attacks in ResolveSet::Act, apply_damage in ResolveSet::Damage, end_run_on_player_death in TurnSet::React and process_deaths in CleanupSet::Remove.
bury_the_dead is the exception and runs in Last, which is how the dead are promised to linger until the frame ends without naming one of the systems that has to see them go.
Resists is a component rather than a requirement, so an actor with none meets an empty ladder and a game with no resistances pays nothing.
Nothing here decides who strikes whom: minds choose for monsters, Bump turns a walk key into an Attack when a foe is in the way, and an ability’s damage arrives as the same DamageEvent a sword’s does.
CorePlugin registers Intent<Attack> itself so that a bump into a foe runs in a game with no combat at all, and add_action::<Attack> adds the sweeper that refuses one nobody resolved.
The model
Health is current and max, and Health::full(n) is both.
Faction puts an actor on a side, and CombatRules is the matrix over the sides the game registered: hostile sets a pair both ways, hunts sets one way only, and allied sets help both ways, over a Factions that starts allied with itself and neutral to everyone else.
A grudge that is not returned is the reason the matrix is dense and asymmetric rather than a set of pairs.
CombatRules also carries armor and attack, each an optional StatId, and death_ends_run, which death_is_not_the_end() clears for a game that revives or plays on as a ghost.
MeleeAttack is a kind, a dice roll, an optional cost in hundredths of a step and an optional look; RangedAttack is the same with a range.
Both are built by new and narrowed by costing and looking, so a field only some games want is added without touching every call site.
Armor is flat damage removed and Strikes is a list of extra rolls every hit carries, a flaming blade’s fire or a venomed edge’s poison.
All four sit on an actor or on an item, and that is the whole of how gear fights: a jerkin is an item with Armor(1) and nothing copies the 1 onto whoever puts it on.
Loadout is the one answer to what an entity fights with, in three layers: the actor’s own components, the same components on every item in its Equipped slots in slot order, and the value of the stat CombatRules names.
A worn blow or shot replaces the actor’s own, since a cutlass is swung in place of a fist, while armor, resistances and extra strikes add up.
A worn thing whose Consumable is empty lends no blow or shot, so a spent wand is not fired and its wearer falls back on what is left.
armor, resistances, melee, ranged and strikes are the sums; melee_with and ranged_with also say which worn item it came from; blows is the melee roll followed by the strikes, which is every roll one blow lands.
The attack resolver strikes with it, apply_damage defends with it, and blows and its ranged twin shots are what a forecast is filled from, so what a panel says a fight will cost is worked out from the numbers the fight uses.
Loadout::arms packs both of those, both costs and the shot’s reach into a forecast::Arms, and Combatant::armed reads it at the distance the caller passes: one cell away is the melee rolls, further is the shot while the shot reaches, and past its reach is nothing at all.
That is the rule resolve_attacks picks by, kept in one place, so an actor carrying only a gun forecasts as dangerous across the room and harmless once you are beside it rather than as harmless everywhere.
resolve_attacks picks melee when the two are adjacent and otherwise a shot filtered by line_of_fire; an attack with nothing that reaches still spends an ordinary turn, since what was spent was the aim.
It writes Struck before any damage, naming the worn item the attack came from, because what a weapon does to itself happens at the trigger rather than at the target.
For a worn item it also reports the fire moment at the attacker’s cell, and the hit moment at the target’s cell when the blow or shot lands, so a wand’s charge is spent and its effects land through Effects without combat knowing what either is.
A shot takes its item’s triggers with it as it is fired, so a watched shot from a thing its last charge spent still lands what its hits carry, from a remnant in its place.
Every roll is floored at zero where it is rolled, so a weapon with a bad bonus that rolls low has missed rather than healed.
An attack with a Look is seen: a shot cues a Cue::Flight and a blow a Cue::Burst, and while something watches the cues a shot’s hits wait in Airborne<ShotLanding> until the flight has been seen.
land_shots then drops them on a target still standing, so one killed while the shot flew is missed rather than hurt twice.
shot is where a projectile goes and line_of_fire is that call landing on the cell it was pointed at; a targeting preview draws the same call, so what the player is shown and what the resolver decides cannot disagree.
A DamageEvent carries a Hit, which separates attacker, who triggers on-hit riders, from credit, who gets the kill, so a poison tick credits whoever applied it without recursing its own riders; critical and status are there for the stages and narrators that care.
It also carries a Reach, which is how the damage got there: DamageEvent::new is Effect, what did not travel as a weapon, and arriving names Melee, Shot or Thrown instead, carried through to DamageDealt for whoever puts it into words and read by nothing in the pipeline.
apply_damage builds a Defender and the resistances from the target’s Loadout, runs resolve over the game’s DamageStages, takes the result off health capped at max, and writes DamageDealt and, at zero, DeathEvent.
An Invulnerable target keeps a heal and takes no harm: the hit is still written to DamageDealt, with nothing dealt, so a narrator says it had no effect rather than saying nothing.
A DamageKind is a name and whether armor applies to it, and Resistances is a percentage per kind: 100 is immunity, a negative number is vulnerability, and above 100 absorbs the hit into healing.
The engine ships three stages, SubtractArmor, ApplyResistance and HalveIfBlocked, and the default list holds the first alone.
A negative amount is a mend and goes down the same stages, which is why resistance scales a heal and immunity means nothing can patch the defender up.
process_deaths takes a dead non-player out of the world, the queue and the occupancy index and marks it Dead; end_run_on_player_death writes RunOver inside the turn, so the monster that would have struck the corpse never gets its move.
perceive_reach is combat’s one word to a mind: how far its own shot reaches, read from Loadout::ranged whether the gun is worn or is the monster itself.
Using it
What a game hands combat is two registries and two rules, which is the whole of it in the tutorial’s third step.
// The two registries combat reads: what damage can be, and who hates
// whom. Both are the game's content, named nowhere in the engine.
let kinds = Registry::from_defs(vec![DamageKind::new("bite"), DamageKind::new("kick")]).unwrap();
let sides = Registry::from_defs(vec![FactionDef::new("you"), FactionDef::new("vermin")]).unwrap();
let (you, vermin) = (sides.expect("you"), sides.expect("vermin"));
commands.insert_resource(CombatRules::new(&sides).hostile(you, vermin));
commands.insert_resource(Registries { damage_kinds: kinds.clone(), factions: sides, ..default() });
// What a hit passes through on its way to the target. One stage here;
// resistances, a shield, a critical rule would each be another.
commands.insert_resource(DamageStages(vec![Box::new(SubtractArmor)]));
The line
The engine decides whether a blow is in reach, whether a shot has a line, what it is struck with, what it costs, what it rolls, the order the stages run in, what comes off health and who died.
Which of an actor’s two attacks a forecast counts is the engine’s for the same reason: a panel hands over both sets of rolls and how far apart the two stand, and Arms::at picks, so what a screen says about a fight and what the resolver does in it cannot drift apart.
The game decides what a damage kind is and whether armor applies to it, who hates whom, what mitigates a hit, and what a hit or a death is worth beyond health reaching zero.
DamageStages is a list of boxed DamageStages, so there is no enum of mitigations and no Custom arm: a game’s critical rule sits in the list beside SubtractArmor and resolve cannot tell them apart.
Defender::blocked is never set by the engine, which builds one with blocked: false every time, so HalveIfBlocked is for a caller that fills its own and a game that blocks rolls the block inside a stage of its own.
Accuracy does not exist either: a blow lands unconditionally, and a to-hit roll when a game wants one is a stage that returns zero rather than a change to the resolver.
armor_stat and attack_stat are the whole seam between the registered stats and a blow, which is why a status that hardens the skin and an affix that sharpens the hand both work by moving a stat and neither is named in combat.
A game that registered no stats names neither, and its armor is components alone.
What a weapon does to itself is the game’s, hung on Struck: heat, ammunition, wear, each read off a message that already names the item, so no game recomputes which weapon the loadout would have chosen.
The player’s death ends the run unless death_is_not_the_end() says otherwise, because a game that revives keeps the ending for itself.
Experience, levels, kill credit beyond Hit::credit, wound locations and morale are each a game’s rule over these messages, and the engine should not guess which of them a game wants.
Arithmetic that is really about the rules lives in rl-rules, and what that buys is a test with no App in it: a game’s own stage is proved against a Hit and a Defender filled by hand, and a panel’s verdict about a fight is proved against the same call a real blow makes.
Where it lives
rl-rules is tier 1 and has no Bevy in it: damage.rs is Hit, Defender, the DamageStage trait and a resolve that is a fold over stages, all of it tested against ids made out of thin air.
faction.rs is the dense matrix, which is a table and an index rather than anything that needs a world.
forecast.rs is where the split earns its keep: expected_damage calls the same resolve with the average roll in place of a real one and through the game’s own stages, so a panel that says a fight is deadly got the word from the arithmetic the fight will use.
Arms is what a fight fought at a distance costs it: both sets of rolls, both costs and the shot’s reach, with Arms::at the one place the choice between them is made and Combatant still one set already chosen.
The distance is an argument because only the caller knows where the two stand, and the one thing Arms::at will not check is whether the line of fire is clear, since a forecast a wall may yet block is still the right forecast for the fight the two would have.
rl-bevy is tier 2 and owns everything that touches the world: combat.rs is the components, Loadout, the resolver, the pipeline runner and the deaths, in one file because a blow is one decision and not six.
bump.rs is beside it rather than inside it, since a walk key that comes to a blow is the turn loop’s redirection and works the same in a game with doors and no foes.
Items and equipment
An item is an entity in one of three states: on the ground with a Position and the map it lies on, in a bag listed in a carrier’s Inventory, or worn and also claimed in that carrier’s Equipped slots.
The engine owns the moves between the three and charges a turn for each, and it owns nothing about what an item is.
What wearing one is worth is read off the item itself when a blow is struck, so nothing is copied onto the wearer and nothing has to be unwound when it comes off.
What using, throwing or firing one does is the item’s own triggers, and what that costs it is its charges, which consumables keep.
Two screens come with the system, because an inventory panel belongs where inventory does.
Turning it on
ItemsPlugin declares no needs at all: a game with items and no registries has a bag that works and rows with no names on them.
It registers ItemEvent and the six actions PickUp, DropItem, Equip, EquipFromGround, Unequip and UseItem, and registers DeathEvent as a message it reads so that the dead can drop what they carried in a game with no combat plugin to write one.
Its systems are perceive_belongings in PerceiveSet::Annotate, resolve_items in ResolveSet::Act, fold_gear in TurnSet::React, drop_what_the_dead_carried in CleanupSet::Remove and forget_removed_items in CleanupSet::Requeue.
Every Actor is given an empty StatBlock as it is spawned, with try_register_required_components rather than the plain call, because the status plugin asks for the same one and the order a game lists its plugins in must not matter.
What an item does at a moment is landed by EffectsPlugin, the same subsystem that lands a prop’s trap and an ability, and ItemsPlugin only reports the moment.
ConsumablesPlugin is what makes doing it cost the thing, and it is opt-in on its own: it depends on ItemsPlugin, adds EffectsPlugin if the game has not, runs spend_charges in ResolveSet::Triggers after land_triggers, and runs recharge_charges in TurnSet::React.
It needs no abilities: a game can have things that are used and no AbilitiesPlugin at all.
InventoryPanel takes its rectangle, adds InventoryViewPlugin behind it if the game has not, declares the inventory modal, and registers the intents the screen writes whether or not the plugin that resolves each was added, so a game without throwing still has a bag.
ContainerPanel does the same for the container modal and depends on PropsPlugin, since what it shows is a prop’s contents.
Either view plugin can be added alone by a game that wants the data and draws it itself.
The model
Inventory is a Vec<Entity> in pickup order, and a worn item stays listed in it, so a bag is the whole of what is carried rather than what is carried and not used.
Equipped wraps an Equipment over the slots the game registered as SlotDefs, and Wearable is the EquipShape that says where an item goes: any_of are the slots it may take, first free one wins, and also are the slots it claims wherever it went.
A two-hander is EquipShape::in_slot(main).and_claims(off) and a ring is in_any([left, right]), and equip answers with everything it displaced or with an EquipError, which is NoSlot for a shape that names nowhere to go and UnknownSlot for one that names a slot this wearer does not have.
Stack { key, count } makes an item countable: picking one up merges it into a carried item with the same key instead of adding a row, and the key is the game’s, usually the definition id.
Tagged is what an item counts as, by registered tag; Enchant is its +N and rolled affixes; Bestows is what it does to registered stats while worn, as (StatId, Op) pairs the game writes when it spawns the item with the enchant already applied.
GearScore is what wearing it is worth in the game’s own units, and only the comparison matters: a mind weighs an item in sight against everything it would displace and puts on what is worth more than all of them together.
EquipFromGround costs EQUIP_FROM_GROUND_COST, half a step more than picking up or putting on alone, so taking up a sword in the middle of a fight is a real choice rather than a free one or two turns wasted.
resolve_items reads all six intents into one list, so one turn spends one item action whichever kind it is; an impossible one is refused for the player and charged as a wait to anyone else, the way an impossible move is.
A use of an empty Consumable is impossible, so it costs the player nothing.
An accepted use writes Fired for the use moment at the user’s cell, beside the ItemEvent.
ItemEvent is what happened: PickedUp with the stack it merged_into when it merged, Dropped, Equipped, Unequipped for an item taken off by choice or displaced, Used, and Thrown.
fold_gear runs whenever Equipped changed and rebuilds rather than edits: every modifier tagged Source::Item is dropped and each worn item’s Bestows put back in slot order, so an item taken off takes its changes with it and a run restored from a save rebuilds its gear modifiers for nothing.
A status’s modifiers carry their own tag and are left where they are, and so is anything the game filed under Source::Game.
Loadout reads an item’s Armor, Resists, MeleeAttack, RangedAttack and Strikes straight off it at the moment of a blow, which is the other half of wearing something and needs no fold at all.
Triggers is what a thing does at its moments, the component a prop carries too: use lands on the user where they stand, land where a throw comes down, fire and hit when a worn weapon shoots and strikes, each over its Area.
Consumable is what those moments cost the thing: left of max charges, WhenEmpty::Destroyed or Kept at zero, and an optional Recharge on the clock.
SpendingMoments says which moments spend, use, land and fire unless a game adds its own, and spend_charges takes one for each: one off left, else the next unit of the Stack starts full, else the item is marked Spent, or kept empty.
A Spent thing is kept the way the dead are, so the log names it in its own colour: remove_spent takes it out of play at the end of the pass, no longer an Item, so forget_removed_items drops it from every bag and slot, and off the map, and bury_spent despawns it in Last.
Absent, the thing survives every moment, which is what a tool is, and a thing with charges and no trigger for a moment still spends, which is how a plain wand’s shot costs one.
recharge_charges counts the clock’s time into a refilling thing’s Recharge and gives back a charge for each full period.
drop_what_the_dead_carried lets a dead non-player’s bag fall where it died, and forget_removed_items drops a despawned item from every bag and every slot.
perceive_belongings tells the mind holding the turn what it carries that it could throw and what lies in sight worth having, and only a mind with the wits to pick up or put on is told the second.
InventoryView is the player’s bag as plain data: an ItemRow per item with its label, glyph, count, the slot it is worn in and the slots it could go in by name, how far it flies and what it strikes for thrown, its armor, its blow, its shot, its extra strikes, what it bestows by the stat’s name, its tags, its charges and whether it is empty, and the facets a game pushed.
Every number on a row is the item’s own component, the one Loadout reads, so an item spawned to fight is described for free and a game says nothing twice.
used is what its triggers say of themselves in the registries’ names, one line per effect led by its moment, use: mends 5 or on landing: 3 kinetic in a burst of 1, and usable() says whether the use key does anything, read off the use trigger and the charges rather than off the description so a terse effect does not lose the key that uses it.
InventoryPanel is a modal the engine runs end to end: InventoryKeys opens and closes it and wears, drops, uses and throws the row picked out, and the footer offers only the keys that do something to that row.
The use key uses a thing where the player stands, and a row with no use trigger or no charge left is not offered it; aiming is the throw key’s, which opens the targeting cursor, and firing is combat’s.
The screen does not read a key in the frame it opened, so a game that opens the bag on one of the bag’s own keys, as Foundry’s t does, opens it and no more.
Every action closes every screen, since it spends a turn and the turn loop assumes nothing is up while it runs.
ContainerView is what the open container holds, as the shared Row, and which one is open is OpenContainer, the screen’s own state rather than the world’s: a container is not open, it is being looked into.
ContainerPanel opens itself on an Interacted whose verb is open on a Container, walks the rows, writes Take { from, item } for one or for all of it, and closes when the container goes out of reach.
Taking does not close the screen, because opening already cost what the definition said and a crate emptied a piece at a time would otherwise cost a screen a time.
A container that was emptied is still shown, empty, until the screen closes, since a screen that vanished as the last thing came out would read as a fault.
Using it
An item is an entity carrying the components that say what it is worth, and Warren’s floor is littered with two of them.
/// What the floor is littered with: bread that mends, rocks that fly.
fn litter(commands: &mut Commands, rats: &Rats, p: Point, bread: bool) {
if bread {
commands.spawn((Item, Crust(8), Name::new("a crust of bread"), Position(p), Glyph::new('%', Color::srgb(0.85, 0.72, 0.40)).on_layer(2)));
} else {
commands.spawn((
Item,
Throwable { range: 7, strike: Some((rats.bite, DiceRoll::new(1, 4))) },
Name::new("a rock"),
Position(p),
Glyph::new('*', Color::srgb(0.66, 0.66, 0.70)).on_layer(2),
));
}
}
What an item does at its moments and what doing it costs are two components, and Foundry’s armory hangs them on every item whose definition has them.
// What it does at its moments, a stim's use and a grenade's landing,
// built once by the armory and shared by every copy; the engine lands
// them. And what doing it costs the thing, which the engine spends.
if let Some(triggers) = armory.triggers.get(id.index()).filter(|t| !t.0.is_empty()) {
e.insert(triggers.clone());
}
if let Some(c) = d.consumable {
let charges = Consumable::new(c.charges, c.when_empty);
e.insert(match c.recharge {
Some(every) => charges.recharging(every),
None => charges,
});
}
The two screens are added the way every other panel is, each with its own rectangle and the words the engine has none for.
InventoryPanel::new(screen.pack).title("Pack").called("pack").empty("Nothing but dust."),
// What is inside a crate or a wreck, opened by walking into it or
// by the key that does what is here.
ContainerPanel::new(screen.chest).empty("Stripped already."),
The line
The engine knows that an item exists, where it is, what moving it costs, what putting it on displaces and what its combat components are worth in a fight.
It does not know what an item is: there is no ItemKind enum, no Custom { id } and no table of categories, and a potion, a cutlass and a key are the same Item told apart by the components a game hung on them and by the ids those components hold.
Every one of those ids points into a registry the game filled, so slots, tags, stats, damage kinds and affixes are all content and adding a twelfth hardpoint or a fourth ring finger is a registry entry.
An item never lends an ability: an ability is something an actor knows, and Known is only ever what the actor itself learned.
What an item does is its triggers, and it is aimed in exactly two ways, each owned by a system that already existed: thrown, where its land trigger fires where it comes down, or fired as a weapon, where its hit trigger fires on whoever the shot struck.
There is no aimed use with a cursor of its own, because that would be an ability by another name.
Foundry drew the line the hard way: its stims were abilities for a day, which put two consumables on the abilities screen beside the one thing the commando knew, and its grenades were abilities whose cost destroyed the item that lent them.
A trigger with no list of its own lands the item’s shared effects, so a draught drunk and a draught thrown are one list delivered two ways.
A use of an item with a use trigger is said by the narrator, You use a stim.; an item with no trigger reports ItemEvent::Used and the game answers it, in its own words, which is the escape hatch and is meant to be one: the bag cannot know that a crust of bread mends, and a use that did nothing would still have spent a turn.
Nothing about wearing goes through effects, and none is offered: what a worn thing does is Armor, Resists, an attack and Bestows, every one of them a standing state, where a list of effects lands once and is done.
The engine never names anything either, which is why InventoryLayout carries a title, a word for the bag and a line for when it is empty; the engine has no word for a sea chest and will not invent one.
Affixes fold down to the vocabulary the rest of the rules already speak, changes to registered stats and dice of a registered damage kind, so an enchanted blade needs no new machinery on the way to a blow.
The container screen is take-only, because putting things back is a stash mechanic and the engine has no opinion about stashes.
Weight, bulk, encumbrance, durability, identification, cursed gear and prices are each a game’s rule over these components and these messages, and none of them is assumed here.
Where it lives
rl-rules is tier 1 and has no Bevy in it: equip.rs is generic over the item handle, so the slot algebra, what a shape claims and what an equip displaces, is proved with integers standing in for items and no App anywhere.
Equipped is that same code at Equipment<Entity>, which is the whole of what the Bevy layer adds to it, and a tool that weighed a loadout of definition ids would add no more.
affix.rs is the rolling and the naming, and it ends at (StatId, Op) and (DamageKindId, DiceRoll) rather than anything an item has to interpret.
rl-bevy is tier 2 and owns the three states and the moves between them, in items.rs, which is also where the fold of what worn gear bestows lives, since only that layer knows what is worn right now.
consumable.rs is beside it rather than inside it because a bag that works is not a bag that costs anything, and a game may want the first without the second; what the two share is one message, Fired.
The triggers it spends for belong to the effects subsystem, and a prop carries them the same way: an item and a prop differ in which moments they report, never in how a list was built, rolled or landed.
rl-ui holds the two screens, each split the way every panel is: the view is plain data with no colour and no rectangle in it, the collector refills it in ViewSet::Collect, and the presenter takes its rectangle in its constructor and draws in PresentSet::Overlay.
The split is what lets a game that wants a different bag screen keep the view and write its own presenter, and what lets the view be tested with a bag filled by hand.
Minds
A mind is a priority list of tactics, asked in order, and the first that answers has spent the turn.
What it is asked about is a Snapshot, the world from one actor’s point of view, opened when that actor’s turn is dealt and filled by every plugin the game added that knows something a mind should.
The decision that comes back becomes the intent of the action that answers it, so a monster writes the same Intent a key does and is claimed and refused by the same resolvers.
Deciding is tier 1 and sees no world: a tactic reads the snapshot and asks for the way toward or away from cells, so what a monster does on a turn is settled by code with no App under it.
Turning it on
MindsPlugin is every non-player deciding its own turn, and it is opt-in: a game that moves its monsters with systems of its own leaves it out.
It puts sense in DecideSet::Sense, begin_thinking in PerceiveSet::Begin, perceive_roster in PerceiveSet::Roster and decide_minds in DecideSet::Minds, and adds MindRng, a stream of its own, so writing one more tactic cannot shift combat’s rolls.
It declares depends_on::<FovPlugin> and nothing else, because a mind’s sight is a Viewshed of its own cast by the function that casts the player’s.
Not combat: without CombatPlugin there is no faction matrix, everyone a mind sees is one of the others, and it steps round them rather than at them.
It registers the Attack action itself, so a blow decided in a game with no combat is refused by the sweeper rather than left holding the turn, and it registers the messages for a use, a pickup, an equip and a throw so that a brain reaching for one in a game without that subsystem writes into a message nobody reads.
DecideSet::Perceive runs under a_mind_holds_the_turn, so a pass for the player costs no contributor a dispatch.
A Mind put on an entity in a game with no MindsPlugin is reported once, by name, rather than leaving a monster that never moves.
The model
Mind(Arc<Brain<Entity>>) is the component, shared because most monsters of a kind think alike, and it requires Intelligence, CameFrom and a Viewshed.
Brain::then appends a tactic below the ones already there, and decide returns the first Decision a tactic gave together with the name of the tactic that gave it.
A Tactic is a name for that trace and an evaluate returning a Decision or None to let the next one try.
Decision is Step, Attack, Ability, Wait, PickUp, EquipFromGround, Throw, or Own(Box<dyn Choice>) for an action of the game’s own; a step onto a shut door is written as an Open instead, since the mind knows what it is walking into.
Perception(i32) is how far a mind sees and notices, DEFAULT_PERCEPTION of 8 without one, and the cast is a disc read through the light, so a monster in the dark sees what is lit, what its DarkSight reaches and what it is touching.
Intelligence(Wits) is what a mind is able to do whatever its brain would like: FLEES, SEARCHES, OPENS_DOORS, PICKS_UP, EQUIPS and THROWS, with MINDLESS, ANIMAL and SAPIENT presets, sapient unless the spawn says otherwise.
Profile(MovementProfile) is the movement class it paths with, and CameFrom the cell it stepped from last, so a wanderer drifts rather than dithers.
Snapshot carries me, enemies, allies, others, items, props, missiles, usable, reach, last_known, came_from, wits and the game’s own senses.
An ActorView holds health and faction as options, so a civilian in a game with no combat is still someone a mind sees and steps round, and is_hurt is false when health is unknown rather than true.
Thinking is that snapshot while it is being filled, plus mark_hazard for a cell no mind will step on and offer_trail for something worth walking to, the freshest offer becoming last_known when the snapshot closes.
The four phases fill it in turn: Begin opens it, Roster sorts everyone in sight into the three lists by the faction matrix, Filter is where stealth drops the hiders the mind has not noticed and offers what it lost, and Annotate is where combat says how far its own shot carries, items what it carries and sees lying about, abilities what it may use, props what stands about, fire where not to step and hearing where a sound came from.
decide_minds sorts the snapshot once, there and nowhere else, so the order the contributors ran in cannot reach a tactic.
TacticCtx then offers step_toward and step_away_from over FlowFields, keyed by the goal cells, the movement class, whether the walker opens doors and which way it is going, stamped with the map’s cost epoch and capped at FIELD_CACHE: fifty hunters after one player cost one flood.
It also offers can_step, which refuses an occupied cell and any cell marked a hazard, blocks_shot and blocks_burst, the two predicates the ability resolver flies and bursts by, and the turn’s stream.
can_step answers whether a cell may be stood on and says nothing about the way in, so a tactic that picks a neighbour for itself rather than taking one a field offered pairs it with the resolver’s corner rule: a diagonal that squeezes between two cells the actor cannot stand on is refused silently, and a mind deciding on one would decide the same way again on every turn until something moved.
The twelve shipped tactics are MeleeAdjacent, Hunt, FleeWhenHurt, SearchLastKnown, Keep, Hover, Wander, GiveWay, UseAbility, ThrowAtRange, ShootAtRange and Scavenge.
Keep is one tactic for both sides of keeping station, parameterised by the roster it reads: Keep::allies(keep_within, no_closer_than) is what a companion is and Keep::enemies(..) what a spotter or a skirmisher is, each closing past the first distance, backing off inside the second and leaving the band between to the next tactic, and it reports itself as follow or shadow, because a trace that says shadow says more about what a probe did than one that says keep.
app.add_choice::<A>() registers an action that is also a Choice and routes every MindChose carrying an A into its Intent in DecideSet::Game, and Snapshot::add_sense with sense::<T>() carries a game’s own knowledge in by type, one per type.
Using it
Heist’s watch is a brain per kind, built from that kind’s own fields, and a spawn that carries it.
fn load(names: &Names, faction: FactionId) -> Self {
let defs: Registry<WatchDef> = names.load(WATCH_RON).unwrap_or_else(|e| panic!("assets/watch.ron: {e}"));
let mut table = BandedTable::default();
let mut brains = Vec::new();
for (id, def) in defs.iter() {
let (lo, hi, w, gmin, gmax) = def.spawn;
table.push(BandedEntry::new(id).bands(lo, hi).weight(w).group(gmin, gmax));
// Strike what is in reach, hunt what is seen, search where it was
// last seen; a watcher with hands relights the lamps on its round;
// otherwise drift.
let mut brain = Brain::new().then(MeleeAdjacent).then(Hunt).then(SearchLastKnown);
if def.wits.has(Wits::OPENS_DOORS) {
brain = brain.then(RelightLamps);
}
brains.push(Arc::new(brain.then(Wander { chance_pct: 30 })));
}
Self { defs, table, brains, faction }
}
fn spawn(&self, commands: &mut Commands, id: Id<WatchDef>, at: Point) -> Entity {
let d = self.defs.get(id);
commands
.spawn((
(Actor, Blocks, Kind(id), Position(at), Speed(d.speed), Faction(self.faction), Health::full(d.hp), Armor(d.armor)),
(
MeleeAttack::new(d.kind.id(), d.attack),
Perception(d.perception),
DarkSight(d.dark_sight),
Notice(d.notice),
Hearing(d.hearing),
Mind(self.brains[id.index()].clone()),
Intelligence(d.wits),
Name::new(d.name.clone()),
Glyph::new(d.glyph, Color::srgb(d.color.0, d.color.1, d.color.2)).on_layer(5),
),
))
.id()
}
The line
The engine owns when a mind is asked, what it is told and what becomes of the answer; the game owns the list of tactics, so what a monster is for is never the engine’s opinion.
A mind knows only what the plugins the game added put in front of it, which is why a game with no stealth has minds that see on sight and a game with no combat has minds that see no sides.
Nothing a game extends this with is numbered: an action of its own arrives as a Choice found by type and knowledge of its own as a Sense found by type, so two games’ additions cannot collide.
A game’s own contributor goes in PerceiveSet::Annotate, its own answer to its own choice in DecideSet::Game, and neither orders itself after another crate’s system function.
Two contributors in one phase never write the same list, and the sort at the head of the decision is the guard, so which one the executor ran first cannot reach a replay.
A game that decides one actor’s turn itself claims that decision in TurnSet::Decide, and the stage never opens for it, so no contributor works for a decision nobody will make.
Wits are the engine’s whole vocabulary for what a mind is able to do; anything finer, a post to return to or a pack that hunts together, is a tactic and a Sense of the game’s.
Where it lives
rl-rules is tier 1 and has no Bevy in it: brain.rs is Brain, Tactic, Decision, Choice and the Fields trait a tactic asks for the way through, snapshot.rs is what an actor knows, tactics.rs the shipped list and wits.rs the capabilities.
A tactic never sees a DijkstraMap or an Entity, only a Snapshot and a Fields, so one is tested against a snapshot built by hand and NoFields with no App anywhere, and the whole priority contract is arithmetic over ids made up on the spot.
rl-bevy is tier 2 and owns the perceiving and the acting: minds.rs has the components, Thinking, FlowFields, decide_minds and add_choice, and plugin.rs fixes DecideSet and its four PerceiveSet phases, because the order the contributors run in is one decision and not six.
Each contributor lives with its own subsystem rather than here, so a subsystem added later adds a system to a phase and edits nothing in minds.rs.
Sight and lighting
Sight is a viewshed per actor: a symmetric shadowcast from where it stands, out to its range, over the loaded window. Lighting is a field over that same window, cast from every source that glows and blended with one ambient level for the map. Where both are on, the field cuts a viewshed down to what is lit at or above a threshold, what is within the actor’s dark sight, and what the actor is touching. The player and every mind are cast and cut by the same code, so an unlit monster is not seen at all and a lamp-bearing one is seen coming.
Turning it on
FovPlugin recasts every stale viewshed once a frame, in EngineSet::Fov.
With it alone there is no dark: visible is a copy of line, and an actor sees every tile it has an unobstructed line to.
LightingPlugin adds the field and the gate.
It inserts Lighting::dark(), casts in EngineSet::Light ahead of sight, burns Fuel in ResolveSet::Effects, and resets Lighting to dark on a new run so that each run writes its own ambient.
Adding it without FovPlugin builds a field nothing reads, because the gate is applied by the one function that writes a viewshed.
Both declare depends_on::<CorePlugin> in finish, which runs after every plugin is added, so the order they go into add_plugins does not matter.
A game that adds neither has no sight: nothing ever casts a Viewshed, can_see is false on every tile, and the map view draws nothing.
The model
LightSource is the one component for everything that glows.
intensity is the brightness at the source’s own tile, radius how far it reaches, falling to exactly zero at the rim, and color its hue.
An entity that takes no turns is a fixture in the static layer and an actor or a carried item is in the dynamic layer; either is recast when its sorted list of emitters differs from the last cast, and both are recast whole when the window moves, when play crosses to another map, or when the map’s opacity changes.
An item on the floor lights the tile it lies on, and once picked up it sheds from its carrier’s tile instead.
DarkSight(pub i32) is how far an actor sees with no light at all; absent, it sees only what it is touching.
Fuel(pub u32) is turns of light left, burned one per whole turn, and at zero the engine removes the LightSource and writes LightEvent::BurntOut.
The Lighting resource carries ambient and threshold as public fields and its three light layers privately; at gives a tile’s light and is_lit compares that light against the threshold.
threshold starts at DEFAULT_THRESHOLD, which is 16.
Viewshed holds both bit grids: line is the shadowcast, and visible is what the gate left of it.
gate writes the second from the first, and perceives answers the same question about a single target without a viewshed; with no Lighting it is always true.
A mind’s range comes from Perception, and an actor with RevealsMap writes visible into Knowledge, so a dark corridor is not remembered until something lights it.
Using it
The tutorial’s lantern is a LightSource constant that a key puts on the player and takes off again.
/// What the lantern sheds when it is open: a warm, slightly restless pool.
const LANTERN: LightSource = LightSource::new(150, 7, Rgb::new(255, 210, 140)).flickering(30);
/// The player and whether its lantern is open, while it holds the turn.
type Lantern<'w, 's> = Query<'w, 's, (Entity, Has<LightSource>), (With<Player>, With<MyTurn>)>;
/// `t` opens the lantern or shades it, and spends the turn either way.
///
/// The light is a component on the player, so shading it is removing one.
/// Nothing else changes: sight is still sight, and the explored map still
/// remembers what the light once reached.
fn tend_lantern(
keys: Res<ButtonInput<KeyCode>>,
mut commands: Commands,
player: Lantern,
mut waits: MessageWriter<Intent<Wait>>,
mut log: ResMut<MessageLog>,
turns: Res<Turns>,
) {
if !keys.just_pressed(KeyCode::KeyT) {
return;
}
let Ok((entity, lit)) = player.single() else { return };
if lit {
commands.entity(entity).remove::<LightSource>();
log.muted("You shade the lantern. The warren closes to arm's length.", turns.turn_number());
} else {
commands.entity(entity).insert(LANTERN);
log.notice("You open the lantern. The dirt comes up warm around you.", turns.turn_number());
}
waits.write(Intent::new(entity, Wait));
}
The line
Whether a thing glows is the game’s call, made by inserting or removing a LightSource: the engine reads what is there and lights nothing on its own.
ambient is a plain field the game writes, once at startup for a dungeon and from a system of its own for a surface with nights.
There is no clock hook and no notion of a day in the engine, so a game without a day cycle carries none of that machinery.
flicker is presentation: it reaches the renderer as the waver channel of a tile’s light, and gameplay reads the steady intensity, so a guttering torch never changes what is seen and never changes a replay.
The engine puts a light out when Fuel reaches zero and reports it; what becomes of that entity, refilled or dropped or despawned, is the game’s answer.
Lighting is derived and never saved, while Fuel and the presence of a LightSource on an item are the game’s to save with its item state.
What a light means is the game’s too: the engine knows emitters and one ambient level, and never a torch, a sun or a noon.
Where it lives
rl-grid is tier 1 and has no Bevy in it: fov.rs is the symmetric shadowcast, and light.rs is Rgb, Light, Emitter and the LightField that casts and composes them.
Both read a borrowed OpacitySource and write into buffers the caller owns, so a recast allocates nothing and either can be tested without an App.
rl-bevy is tier 2 and has the plugins: fov.rs holds is_stale, cast and update_viewsheds over the Viewshed that components.rs defines, and lighting.rs holds the light components, the Lighting resource, update_lighting, tick_fuel and gate.
rl-render reads the composed field once more, for the color and the waver that gameplay ignores.
Noise
A sound is made on a cell, floods once from it, and is over. Walls stop it, a closed door muffles it, open ground spends a step of its loudness per step walked, and every listener it still reaches with enough left goes to look at the place it came from. A listener hears a place, never a who: it cannot tell a friend’s footsteps from an enemy’s, and whether it sees anything when it arrives is the ordinary sight and notice roll. The engine writes the noise of its own actions and a game writes the rest, as one message, heard one way.
Turning it on
NoisePlugin::new(NoiseRules { step, strike, door, landing, door_muffle }) is the whole of turning it on, and the rules have no defaults, because how loud a step is, is balance.
A loudness of zero makes no noise at all, which is how a game leaves one of the engine’s four sources out without leaving the plugin out.
It chains make_engine_noise and resolve_noise in TurnSet::Listen, which sits after TurnSet::React: a noise a game writes answering the pass is heard in the same pass whichever way the executor ran the two, so a replay cannot tell a game’s sound from the engine’s.
age_heard goes in DecideSet::Notice, beside stealth’s own aging, so a sound and a sighting grow stale at one point in a turn, and follow_heard in PerceiveSet::Annotate, where a mind’s knowledge is filled in.
It declares depends_on::<CorePlugin> and nothing else: the Thinking it annotates, the Stepped the move resolver writes and the DoorEvent a door writes are all CorePlugin’s.
It registers DamageEvent and ItemEvent itself, because a game may have added neither combat nor items, and then those queues stay empty rather than panicking a reader.
Without the plugin nothing is heard however close it is made, and a Hearing on an actor sits there doing nothing.
The model
Hearing(HearingStats) is the component, and an actor without one is deaf, which is how every actor behaved before noise existed.
HearingStats is two numbers: threshold, the whole steps of loudness that must still be on a sound when it arrives, zero hearing it to the last step it carries, and memory, the turns it goes on looking before it forgets, six when a content file leaves it out.
Hearing requires Heard(Awareness), so a listener is ready to remember the moment it is spawned, and what it remembers is stealth’s own Awareness: Alert { at, stale_turns } is exactly “heard something there, this many turns ago”, and a second type with those two variants would be a second thing to keep true.
Footfall(i32) is one actor’s steps in place of NoiseRules::step, heavier for something that clatters and zero for something that pads.
MakeNoise { at, loudness, sound, maker } is a sound made this pass, loudness in whole steps of how far it carries over open ground.
The engine reads maker for one thing only, that its maker does not hear it: its own step is the one sound a listener knows the source of, and without that a listener walking toward a fight hears its own foot louder than the fight and forgets the fight for it.
NoiseHeard { listener, at, sound, maker, left } is written for every listener a sound reached, the player included, and left is what was still on it when it arrived, in hundredths of a step, so the same shout arrives louder next door than across a deck.
SoundId is interned by Sounds, the engine’s four first as Sounds::STEP, STRIKE, DOOR and LANDING; a game declares its own with app.add_sound("shout") and finds it again by name with Sounds::get, so there is no closed list of what can make a noise.
make_engine_noise writes those four: every Stepped the move resolver let through, at the cell stepped to; one sound per attacker per pass however many strikes its DamageEvents carried, at the attacker’s cell, with a mend and damage that has no attacker making none; a DoorEvent opened or closed, at the door; and an ItemEvent::Thrown where the thing came to rest.
resolve_noise then floods each one.
It skips a noise no listener on this map is within loudness Chebyshev tiles of, since no flood carries further than that, and otherwise builds Earshot, a single DijkstraMap reused by every flood so hearing allocates nothing once it has grown, over a square of side 2 * loudness + 1 clipped to the loaded window.
What a cell costs a sound is hearing::carries, read off flags a tile already has rather than a field of its own: what a thrown thing passes, sound passes at one step; what stops one and opens is a closed door, passed at one step and door_muffle more; anything else that stops one is a wall and stops sound.
left_after takes the walk off the loudness and heard asks whether what is left reaches the listener’s threshold.
A listener that heard several in a pass keeps the one that arrived loudest, ties to the lower cell in Point order, so the order they were written in cannot change where it goes.
Every listener is told, but only a listener that is not the player has its Heard set: the player’s turn is the game’s, so the engine says what was heard and decides nothing about it.
follow_heard offers what the mind holding the turn heard to Thinking::offer_trail, unless that mind can see the cell, in which case it forgets it, because seeing the place is having looked and that is what ends a search that arrived.
Stealth offers its lost trails to the same place, the freshest offer becomes Snapshot::last_known with ties to the lower cell, and so which contributor ran first cannot reach a tactic.
NoiseRunning answers whether the plugin was added at all, asked of its message rather than of the components, since Hearing brings a Heard with it whether or not anything will ever fill it.
Using it
A game’s own noise is the same message the engine writes, so Foundry’s probe sounds a klaxon that the deck’s droids hear by the engine’s rules and nothing else.
/// Shouts the alarm for every action an [`Alarm`] carrier finishes while it
/// knows where the player is: a [`MakeNoise`] of [`ALARM_SOUND`] where it
/// stands, as loud as [`ALARM_LOUDNESS`], and a [`PULSE`] on it when the
/// player can see it there. Whoever hears it comes to look; the engine's
/// hearing decides who that is.
///
/// On the probe's own actions rather than on the clock, so a probe frozen
/// on a deck the commando left says nothing, and one that notices and acts
/// in the same pass shouts in that pass. The pulse is a cue like any
/// other, so it holds the turns while it plays and a key skips it; one out
/// of sight would give the probe away and hold the turns for nothing to
/// see, so the noise goes out and the pulse does not.
pub fn shout_alarm(
mut done: MessageReader<ActionDone>,
alarmed: Query<(&Position, &Aware), With<Alarm>>,
players: Query<(Entity, &Viewshed), With<Player>>,
sounds: Res<Sounds>,
mut noise: MessageWriter<MakeNoise>,
mut cues: MessageWriter<Cued>,
) {
let alarm = sounds.get(ALARM_SOUND).expect("FoundryPlugin declares the alarm's sound");
for ev in done.read() {
let Ok((at, aware)) = alarmed.get(ev.actor) else { continue };
if !players.iter().any(|(p, _)| aware.knows(p)) {
continue;
}
noise.write(MakeNoise { at: at.0, loudness: ALARM_LOUDNESS, sound: alarm, maker: Some(ev.actor) });
if !players.iter().any(|(_, sight)| sight.can_see(at.0)) {
continue;
}
cues.write(Cued { actor: ev.actor, cue: Cue::Burst { on: vec![Anchor::on(ev.actor, at.0)], look: LookOf::Given(PULSE) } });
}
}
And what the engine’s own actions cost is one constant, handed to the plugin as it is added.
/// How loud the engine's own actions are on a deck. A blow or a shot
/// carries ten steps, so a firefight draws the droids in earshot; steps,
/// doors and a thrown thing landing make no sound worth hearing over the
/// machinery, and a shut bulkhead takes three steps off anything that
/// passes it.
pub const NOISE: NoiseRules = NoiseRules { step: 0, strike: 10, door: 0, landing: 0, door_muffle: 3 };
The line
The engine decides how far a sound carries and who it reaches; a game decides what is worth making a sound about, and how loud.
A listener is told a place, and the engine tells it nothing about what happened there: sound and maker ride along for a game’s own reactions and the engine reads neither, except to spare a listener its own.
Hearing and stealth are two levers that never read each other: Stealth::quiet is how hard you are to see and Footfall is how loud you are to walk, and all hearing does for noticing is bring a monster close, where the notice roll is likely to land.
What follows a sound is a tactic reading last_known: SearchLastKnown walks to the place, Keep::enemies keeps station on it once nothing is in sight and Hover holds while it is remembered, each of them only for a mind whose Wits hold SEARCHES, so a mind without the wit, or with none of those tactics in its brain, hears the sound and does nothing with it.
Nothing here persists between turns: a noise never outlives the pass it was made in, so there is no field to step and nothing to save, and Heard is lost on load the way awareness is, which is a monster on its way to look at a sound forgetting it.
A game that adds the plugin and authors no Hearing anywhere hears nothing at all, which is the right way round and the likely first report.
What the player reads off it is Alert::Searching on a nearby row, which is exactly this: something on its way to a noise that has not seen you, named in the game’s own words through AlertWords.
Per-weapon loudness and per-tile deadening are not here: a knife and a pistol are both strike, and a thick carpet is a TileProps field on the day a game asks for one.
Where it lives
rl-rules is tier 1 and has no Bevy in it: ai/hearing.rs is four items and no world, the stats, what a cell costs a sound, what is left after a walk and whether that reaches a threshold, so the property the whole subsystem rests on, that a sound of loudness n reaches a threshold of zero at exactly n steps and not one more, is proved as arithmetic with no App under it.
ai/awareness.rs is where Awareness lives, shared with stealth, so a heard place and a lost subject go stale by one state machine and a panel reading either reads one type.
rl-bevy is tier 2 and owns where a sound goes: noise.rs is the plugin, the components, the messages, the flood and the two systems that reach into a mind’s turn, and it asks hearing::carries what each cell costs rather than deciding that itself.
plugin.rs fixes TurnSet::Listen between the pass’s reactions and its record, which is the one ordering decision that makes a game’s noises and the engine’s indistinguishable in a replay.
Stealth
Without this a mind acts on everything its own sight reaches the turn it first reaches it, which leaves a player nothing to break and a monster nothing to regain. Stealth is the layer between “could be seen” and “has been seen”: a roll to notice, and a memory that decays. Noticing is two knobs rather than a radius, because a radius alone is a hard line the player learns to stand behind and a chance alone is a lottery with no readable edge. What an observer knows is per observer and per subject, so a monster may be unaware of you and perfectly aware of the thief beside it, and one that loses you searches where it last saw you before it forgets.
Turning it on
StealthPlugin adds three systems, and the message and the stream they need, and nothing else: update_awareness in DecideSet::Notice, before a mind decides; filter_unnoticed in PerceiveSet::Filter, after the roster stage put everyone in sight into a snapshot; and wake_on_damage in TurnSet::React, where a turn’s consequences land.
It declares depends_on::<MindsPlugin>, since noticing is a thing minds act on, and the stream is StealthRng, so a tactic added to a brain or a blow struck elsewhere cannot shift which turn a guard spots you on.
Both sides have to be authored before anything changes: a Notice absent means the observer sees on sight, which is the behaviour before stealth existed, and a Stealth absent means the subject never hides.
That is the right way round, and it is why “I added the plugin and nothing happened” is the likely first report.
The plugin is opt-in per game and the components are opt-in per spawn, so a game may carry it and still have places where nothing hides.
Lighting is optional under it: with no Lighting resource every cell counts as lit, and with one, a subject standing in light widens the observer’s certain radius by lit_bonus, which is zero unless a game says otherwise.
Combat is optional too: with no CombatRules nobody has a side, so everyone is at odds with everyone and every observer rolls against every hider.
The model
NoticeStats is the observer’s half: certain, the tiles inside which it spots you whatever the roll; chance_pct, its chance a turn beyond that; lit_bonus, added to certain while you stand in light; and memory, the turns it keeps looking after losing you, six when a content file leaves it out.
StealthStats is the subject’s: quiet off the certain radius and subtlety off the chance, both defaulting to nothing.
certain_radius is certain plus the light bonus less quiet, floored at one, so no stack of gear hides you from somebody standing next to you; notice_chance is the chance less subtlety; notices is either of them answering yes.
Notice(NoticeStats) and Stealth(StealthStats) are the components, one name per tier so that globbing both crates into a prelude does not put two types called Notice in it.
Notice requires Aware(BTreeMap<Entity, Awareness>), keyed only by the subjects the observer has an opinion of, which in most games is the player alone: a map for correctness and one entry in practice, and a BTreeMap because it is read on the decision path and walked in a fixed order.
Awareness is Unaware or Alert { at, stale_turns }, and the third state is derivable rather than stored: alert and in sight is hunting, alert and out of sight is searching.
saw(at) resets the staleness as well as the position, which is what stops a monster giving up on the turn it catches you; lost(memory) returns to Unaware once the count passes memory; alerted_to(at) is anything that tells it without its looking, and is the same call.
update_awareness runs for the actor holding the turn and no other, and never for the player, which carries no Aware worth filling and never rolls to notice whatever Notice is put on it, so noticing costs one roll per subject per monster-turn and nothing per frame.
It walks its subjects in spawn order rather than archetype order, since which subject gets which roll must not depend on how the world happens to be laid out.
A roll decides only whether an unaware observer becomes aware: one already alert keeps its subject for as long as it can perceive it, so a monster in plain view does not lose you to a bad number, and losing takes memory turns out of sight while noticing takes one.
What “could be seen” means is the observer’s own Viewshed and within_reach of its Perception, DEFAULT_PERCEPTION without one, so the minds, the roll and the panels can never disagree about who could be seen.
Noticed { observer, subject, at } is written once, on the flip from unaware, and never again while the awareness holds.
filter_unnoticed takes the hiders the mind holding the turn has not noticed back out of its enemies and leaves a mind that keeps no Aware alone, then offers every subject it is alert to but cannot see as a trail through Thinking::offer_trail.
Noise offers its own to the same place, the freshest becomes Snapshot::last_known, and SearchLastKnown walks to it.
wake_on_damage wakes whoever takes a blow from something carrying Stealth and points it at the attacker’s cell: a mend is not a blow, and a blow armor stopped at zero still wakes it.
StealthRunning answers whether the plugin was added, asked of its message rather than of the components, because Notice brings an Aware with it and a game that authored observers without the plugin would otherwise have monsters that notice nothing forever.
Watchers answers who is watching whom by the rule the minds act on: an observer that keeps an Aware watches what it knows about, one that does not watches whatever its own sight reaches, and neither watches anything it is not at odds with.
Using it
An observer is authored where it is meant to be fooled, so Corsair puts Notice on what walks its caves and deliberately not on what walks its islands in daylight.
/// Spawns one `id` standing at `p` on the current map, underground, where
/// whatever carries a lantern has it lit.
pub fn spawn_underground(&self, commands: &mut Commands, id: rl_engine::rl_core::Id<MonsterDef>, p: Point) -> Entity {
let e = self.spawn(commands, id, p);
if let Some(lantern) = self.defs.get(id).lantern {
commands.entity(e).insert(lantern);
}
// Only below: the caves are dark and have somewhere to hide, and
// the islands in daylight deliberately do not.
if let Some(notice) = self.defs.get(id).notice {
commands.entity(e).insert(Notice(notice));
}
e
}
The engine carries Noticed no further than writing it, and a game decides what being seen sets off.
/// A watchman who spots you shouts, and a hound bays: a noise of the
/// game's own at the watcher, which everyone in earshot comes to.
fn raise_alarm(
mut noticed: MessageReader<Noticed>,
mut noise: MessageWriter<MakeNoise>,
player: Query<Entity, With<Player>>,
watchers: Query<&Position>,
sounds: Res<Sounds>,
) {
let Ok(me) = player.single() else { return };
let shout = sounds.get("shout").expect("declared in main");
for ev in noticed.read() {
if ev.subject != me {
continue;
}
if let Ok(at) = watchers.get(ev.observer) {
noise.write(MakeNoise { at: at.0, loudness: SHOUT, sound: shout, maker: Some(ev.observer) });
}
}
}
The line
The engine decides who has noticed whom; a game decides what that is worth.
Propagation is deliberately absent: what a shout carries, how far it goes and who it reaches are content, so a game that wants a squad writes a dozen lines over Noticed rather than accepting the engine’s idea of a squad.
Sneak damage is absent for the same reason, since a multiplier is balance.
Stealth hides a subject from minds and from nothing else: the drawing is untouched, so a monster is never hidden from the player, and two-way stealth would be a render change rather than another component.
A Perception is still the hard cap on how far an actor notices anything at all, and Notice is only the curve inside it, which is how a game gives a guard long sight and poor attention.
notices takes a lit flag rather than a Lighting, so it stays pure and a game is free to decide exposure means standing in water, or on open ground, or having shouted a moment ago.
Light is the one exposure term the engine ships, and it is one number, so a creature with lit_bonus: 0 is one that hunts by something other than the eye without the engine learning a word for it.
Hearing is a separate lever that this never reads: it brings a monster close, and close is where the roll is likely to land.
What the player reads off it is Alert::Hunting on a nearby row, and hunting outranks searching, since something that has seen you is not still wondering about a noise.
Aware is not saved, so a monster that had noticed you has forgotten by the time a continued run begins.
Where it lives
rl-rules is tier 1 and has no Bevy in it: ai/awareness.rs is the two stat blocks, the three functions over them and the state machine, with the caller doing the rolling and the caller deciding what lit means.
That is what lets the properties be proved rather than watched: that light widens the certain radius by exactly its bonus and by nothing else, that quiet narrows it and the floor of one holds against any stack of gear, and that lost returns to Unaware on exactly the turn the memory passes while a sighting in between resets the count.
Both stat blocks are serde-ready, so an observer’s attention and a subject’s quiet are written in a bestiary file rather than in Rust.
rl-bevy is tier 2 and owns the rolling: stealth.rs is the components, Aware, the stream, the three systems and the system parameters that answer whether stealth is running and who is watching.
Watchers lives there rather than in a panel because the vitals strip and the nearby rail must read the same answer the minds act on, and the bug that put it there was a strip reading hidden while a cutthroat cut the player down.
Fire and gas
A field is one value per tile, stepped a whole turn at a time: a rule reads the grid as it stood and writes each cell’s next value into a second buffer, and the two swap. Fire is a field of the turns each burning cell has left, spread by rules the engine owns through whatever a cell has to burn. Gas is one such field per registered gas, holding a concentration, spread and faded by numbers a game’s content names. Both are kept per map, so smoke left hanging in a corridor is hanging there still on the way back, and both are stepped inside the turn that caused them.
Turning it on
FirePlugin inserts Fire and steps it in FieldSet::Fire; GasPlugin inserts Gases and steps it in FieldSet::Gas.
Both of those sit inside ResolveSet::Fields, after the turn’s actions and the triggers they set off, and before what ticks because a turn passed, so a status the flames or a cloud put on whoever stood in them lands and bites on the turn they stood there.
Fire runs before gas, so a fire that burns a vapour away and gives off smoke has that smoke spread on the same turn.
Each is opt-in on its own, because a game may want smoke and no fire, or fire and nothing in the air.
FirePlugin declares needs::<FireRules>, needs::<Registries> for which gases burn, and needs::<Seed> for the rolls; GasPlugin declares needs::<Registries> for the gases themselves.
Both check again as play begins that what the content asks for can happen: fire that inflicts a status wants StatusPlugin, fire that smokes wants GasPlugin, and a gas that inflicts a status wants StatusPlugin, each refused by name rather than left to do nothing.
Adding FirePlugin registers the Ignite effect, which writes a Kindle for every cell a landing covers, and GasPlugin registers Emit, which writes a Release, so an ability, a trap or a grenade reaches either field without knowing there is a field.
Both reset on a new run, and both declare depends_on::<CorePlugin>.
The model
TileField<T> is the field: step asks a rule for every cell’s next value through an Around that reads the grid as it stood, and then the buffers swap.
Nothing a step changes can move again within the same step, so a fire cannot run across a map in one turn and a cloud spreads the same way whichever order its cells were visited in; the second buffer is kept, so a step allocates nothing.
MapFields<T> holds one per map, follows the map readers read, sets a field aside when play crosses to another and takes it up again on return, and on the streamed surface reframes with the window and drops what leaves it.
Fire is a MapFields<u8> of turns left, read through is_burning, turns_at and burning.
Kindle { at, turns } asks for fire on a cell; a cell with nothing to burn still burns that long and goes out, which is a fireball scorching bare stone, and a tile that both refuses a thrown thing and does not burn refuses it.
Flammable { catch_pct, turns } is what a game puts on a crate or a corpse, and Burning is what is alight, Burning::forever for a brazier that keeps its own cell burning for good.
What a cell burns as comes from three places and the most flammable wins: the Kindling its tile declares through TileProps::burns, a Flammable entity standing on it, and a gas in it whose GasDef::burns is set.
fire::spread burns every alight cell down one turn and rolls each unlit one against catch_chance, which compounds a chance for each of its eight neighbours that is burning.
The rolls are a position_hash of a seed derived for fire over the turn number, not draws from a stream, so the same cell rolls the same whatever order the step visited it in and a save carries no generator for it.
FireRules is the game’s half of fire: inflicts a status on whoever stands in flames, smoke a gas each burning cell gives off, and glow the light it sheds, FIRE_GLOW unless a game says otherwise.
FireEvent reports Scorched, Caught, BurntOut and TileBurnt.
Gases holds one MapFields<u8> of concentration per registered gas, read through at, densest and cells.
A GasDef carries spread, fade, veils_at, burns and inflicts, and nothing else.
gas::diffuse exchanges a share of each difference with every neighbour that can hold gas, so the densest cell never gains, and then takes fade percent and never less than one unit, which is what ends every cloud whatever its shape.
A tile that stops a thrown thing stops gas too, so walls and closed doors hold a cloud back and open water does not.
Release asks for gas on a cell and Vents { gas, amount } gives some off wherever its entity stands, every whole turn.
Gas at or above its veils_at is written into the map’s veil with set_veil, and WorldMap::is_opaque reads that veil beside the tile’s own opacity, so sight and light both stop in thick smoke.
Breathed is sent for every actor standing in any gas at the end of a turn, whether or not the gas does anything to it.
Using it
Gases are content, and a game names them in Registries::gases with the five fields the engine acts on: two rates, a threshold, whether it catches, and what breathing it does; Delve names two.
let gases = Registry::from_defs(vec![
// What burning flesh gives off: thick enough to hide in while it hangs.
GasDef::new("smoke").spread(55).fade(9).veils_at(70),
// The reek off a pool of bile. It burns, and a lungful makes the head swim.
GasDef::new("reek").spread(35).fade(12).burns().inflicts(60, statuses.expect("dazed"), 1),
])
.unwrap();
Fire takes one resource before play begins, saying what its flames do beyond burning, and Delve’s is one line.
// Standing in fire scorches, and burning flesh smokes.
commands.insert_resource(FireRules::new().inflicts(registries.statuses.expect("scorched"), 3).smoke(registries.gases.expect("smoke"), 30));
The line
Whether anything burns at all is the game’s: no tile burns unless its TileProps says how, and nothing else catches without a Flammable on it.
A tile that burns must name the tile it leaves, because a tile that burned and stayed itself could catch again from the neighbour it had lit a moment before, and two of them would pass one fire back and forth forever.
Using the fuel up is what makes every fire end, which is why the engine also takes Flammable off whatever burnt out and burns a vapour away where it caught.
What is left of a burnt crate is the game’s answer: the engine writes FireEvent::BurntOut and leaves the entity standing, to be despawned, charred or looted.
The engine decides how fire catches and how gas moves; a game decides which gases exist, what each does beyond its Breath, and what standing in flames costs.
A concentration is a u8 and a burning cell’s clock is a u8 of whole turns, so neither field anywhere carries a float.
Every burning cell is marked a hazard for the mind holding the turn, which is the one opinion the engine has about what a field means to somebody deciding where to step.
Both fields are the engine’s to save: each exports the cells that hold something on every map, and a restored field is laid out again over whatever window each map has when it is next followed.
Where it lives
rl-grid is tier 1 and has no Bevy in it: field.rs is TileField and the double-buffered step, over which the rule that nothing moves twice in a step is tested on a five-cell grid, and tile.rs is where a tile declares how it burns and refuses by name a tile it would leave that nobody registered.
rl-rules is tier 1 too, and holds both rules as functions over a borrowed field: fire::spread takes its tinder and its rolls as closures, and gas::diffuse takes a GasDef and a test for what holds gas.
Neither needs an App, which is why the properties they exist for are proved over seed ranges rather than watched: that a cloud of any shape clears, and that a firebreak holds whatever the rolls.
rl-bevy is tier 2 and owns where it burns: fields.rs keeps a field per map and per window, fire.rs and gas.rs are the two plugins with their components, messages and content checks, the effects module has the two effects that start them, and plugin.rs fixes the order of ResolveSet::Fields.
Remains
What the dead leave lying where they fell, for a game to say what that means.
Nothing is spawned and nothing is copied: the remains are the dead entity itself, kept past the frame it died in, with whatever the game spawned it with still on it.
What the engine takes off is only what the engine put on, and only what means this is alive and acting.
What it adds is a Remains marking when it died and who got the credit, a Prop so a body is something standing in a cell like any other, and a name saying it is what is left of what it was.
Turning it on
RemainsPlugin adds leave_remains to CleanupSet::Remove, after process_deaths, and declares depends_on::<CombatPlugin>, since without deaths there is nothing to leave.
It is opt-in twice: the plugin decides whether any death leaves anything, and LeavesRemains on the spawn decides whether this one does, so a game leaves wrecks behind its machines and nothing behind its summoned things without a second plugin.
A marker on an actor in a game that never added the plugin does nothing at all.
RemainsPlugin::naming("{what} remains") is the same plugin with the wording set as it is added; the default RemainsNaming says the same thing, and a game may replace the resource later instead.
A body is a Prop, so a game that wants minds to walk to bodies, verbs offered on them or panels listing them adds PropsPlugin beside this one.
Without PropsPlugin a body is something named lying on the floor and nothing reads it.
The model
LeavesRemains is the per-actor half of the opt-in, and carries nothing.
Remains is what an actor becomes: since, what the clock read when it died, and credit, whoever killed it if anyone did and that one is still in the world.
Those are the two things the engine already knew at the moment of death, and neither is a claim about a world.
leave_remains reads DeathEvent, skips the player and skips anything unmarked, and on the rest removes WasLiving and inserts Position, Prop and Remains.
The position is put back because process_deaths took it off with the turn and the cell in the index, and remains lie where the actor fell rather than where a game would have to remember it fell.
WasLiving is the one list of what an actor stops being: Dead, Actor, Blocks, Health, Mind, Perception, Viewshed, Notice, Aware, Hearing and Heard.
Health comes off rather than being left at zero, because a body left with health answers the query the damage pass makes and could be killed a second time.
Notice and Hearing come off for a subtler reason: a watcher is anything carrying a Mind or a Notice that is not Dead, a listener anything carrying Hearing that is not Dead, and remains are not Dead by design, so a body that kept them would go on watching and hearing the player with every enemy on the level dead.
Dead is on that list too, and taking it off is the whole of keeping the entity, since bury_the_dead despawns whatever still carries it at the end of the frame.
RemainsLeft { entity, at } is sent for each one, and entity is the actor that died, so a game reacting in TurnSet::React reads whatever it spawned that actor with.
RemainsNaming is a template with {what} standing for whatever the actor was called, and name_as_remains applies it, once at the death and again when a save lays a body back down.
A body is seen by a mind as a PropView in Snapshot::props, filled by perceive_props in PerceiveSet::Annotate, carrying which entity it is, where it lies and whose it was, and nothing else.
EntityState::remains is how a save holds it: the whole of it is optional, so a save written before remains existed still loads, and the SaveId inside it is optional again, since a death nobody was credited with is still a death.
Using it
A game answers RemainsLeft with whatever a body of its own is, on the entity the engine kept.
/// Makes a droid's remains a wreck: something to go through.
///
/// The engine kept the dead droid and named it from the remains
/// template, so it is already a prop lying where it fell. What it cannot
/// know is what a Foundry wreck looks like or that it is worth opening,
/// which is one kind in `props.ron` and one component here.
pub fn wreck_the_dead(mut commands: Commands, mut left: MessageReader<RemainsLeft>, registries: Res<Registries>) {
let Some(id) = registries.props.id("wreckage") else { return };
for ev in left.read() {
// The glyph comes off with it, so the renderer dresses the wreck
// from `props.ron` rather than leaving it drawn as the droid that
// walked: a `%` on the deck reads as something broken.
commands.entity(ev.entity).remove::<Glyph>().insert(PropKind(id));
}
}
The line
The engine refuses to say what remains are.
There is no glyph, no rot timer, no loot table, and no answer to whether a body can be searched, stripped, rebuilt, eaten or raised: a game answers all of it from its own components, on the same entity, reacting to RemainsLeft.
The one word the engine puts on a body is the name, and that only through a template a game wrote.
Whose it was is the one thing the engine tells a mind, because sides are its own: it registered them and it holds the hostility matrix, so a mind asking whether that one is ours is asking a question answerable without a word of the game’s vocabulary.
Anything finer than that travels as a sense the game pushes.
The engine never removes remains, because how long the dead linger is a rule about a world, and a timer here would be a default every game either accepted without meaning to or switched off.
The player’s death is the game’s alone, marked or not: a run ends on it, and the corpse a game may still want to draw is not taken out from under it.
The cost of keeping the entity is real and worth saying: a game querying its own monsters by a component it added now also matches its bodies, and those queries want Without<Remains>.
Queries on Actor or Mind, which is most of them, are unaffected.
Where it lives
All of it is in rl-bevy, in one file, because there is no rule here to test without an App: the whole subsystem is which components come off an entity and which go on, and the only way to ask that is to kill something and look.
combat.rs owns the death it reacts to and the burial it prevents; props.rs owns what a body is once it is one, which is why nothing about bodies appears in a mind’s snapshot beyond what any prop puts there.
rl-save keeps the fact of the death rather than the game doing so, because a game writes down what a thing is and never that it is dead, and on restore lays the body back down from the two numbers it kept.
Statuses
A status is a registered definition an actor carries for a number of whole turns: what it does to registered stats while it lasts, and what damage it deals each turn. Those two are all the engine acts on, because they are the two it already knows how to undo and how to resolve. Stats are the currency underneath, one registry of ids and four operations on them, which is also how gear and a game’s own traits reach a number. Facts are the other end of the same idea: an outcome as data, tallied into counters and matched against quests, so an achievement is a definition rather than a system.
Turning it on
StatusPlugin declares needs::<Registries>, hinting for statuses that may be empty, and its finish declares depends_on::<CombatPlugin>, because a tick’s damage goes down the pipeline a sword’s does.
It registers Afflict, Cure and StatusEvent, and chains resolve_afflictions ahead of tick_statuses, both in ResolveSet::Effects, which runs before ResolveSet::Damage so a tick lands in the pass that produced it.
Every Actor is given an empty Afflicted and an empty StatBlock the moment it is spawned, so a monster spawned without either still takes a status rather than shrugging it off; the stats are registered with try_register_required_components, since the items plugin asks for the same one and the order a game lists its plugins in must not matter.
They are required on Actor here rather than in the core, so a game with no statuses carries neither component.
FactsPlugin is the opposite shape: it registers Happened and QuestChange, runs track_facts in PostUpdate while play is on, and asks for Quests or Counters, either of which will do.
That is why it asserts on entering Playing rather than declaring a needs: a needs names one resource, and this plugin works with either of two.
A game that inserted neither has nothing listening and is told so, loudly, the moment play begins.
The model
StatusDef is a name, a stacking rule, a list of modifiers, an optional tick_damage as a kind and an amount, and an optional badge of one character a panel may draw.
status::load reads them from RON by name, resolving every stat and damage kind through Names and reporting every unknown name in the file at once, so a game authors statuses in the words its other content uses.
The definition is never deserialized as it stands, because its ids index registries a content file cannot see and a number written in one would land on a different stat the day the stat list is reordered.
Stacking is Refresh, where the longer duration wins and which is the default, Extend, where durations add, Stack, where a second instance sits beside the first, and Ignore.
Afflicted is the Statuses an actor carries, each an ActiveStatus of an id, the whole turns left, and an opaque source so a tick can credit whoever applied it.
Afflict { target, status, turns, by } puts one on and Cure { target, status } takes one off, and each answers with a StatusEvent: Applied for a fresh one, a refresh or an extension, Expired when the time ran out, Cured when it was lifted.
Statuses::apply installs the definition’s modifiers under a Source::Status tagged with the id and the instance, which is what makes removal exact when the same status stacks three deep.
Statuses::tick collects what every status deals into a TickReport, then takes a turn off each and strips the modifiers of whatever ran out.
tick_statuses runs it once per TurnEnd and only for actors on the current map, so a monster on a floor nobody is standing on does not burn down while the player is elsewhere.
Its damage becomes a DamageEvent carrying Hit::from_status, which names the status and credits whoever applied it but leaves attacker empty, so a poison tick cannot set off the riders a blow would.
That event is built with DamageEvent::new, so its Reach is Effect and rides through to DamageDealt as one: a game hanging a rule off a blow can tell a blow from a tick without reading status at all.
A negative ticks amount mends through the same pipeline, which is the whole of what makes regeneration a status like poison.
StatBlock is the Stats underneath: a per-actor base for whichever stats the game overrode, and a flat list of Modifiers each carrying a Source.
Stats::value is base plus every Add, then every MulPct compounded, then AtLeast and AtMost, then the definition’s own min and max.
The Source is a tagged value rather than an opaque number because several systems fold modifiers into one Stats without knowing about each other: statuses remove theirs one instance at a time, the gear fold strips every item’s and puts the worn ones back, and a game’s own sit under Source::Game where nothing in the engine touches them.
A Fact is a registered FactKind, an optional subject, an optional object and an amount that is one unless the fact is about a quantity, all of it in the game’s own numbering.
A Matcher picks facts out by kind and optionally by subject and object, and a fact with no subject is not about anyone.
Ledger is a value per registered CounterDef and a list of Tally rules, and feeding it a fact adds that fact’s amount to every counter whose rule matches.
Tracker is where every quest stands: an Objective is a matcher and a Need, either a Total the matching amounts add up to or a Latest a single fact must reach.
A QuestDef opens when every quest in its after is done and is done when every objective is, and victory on it says the run is won.
Tracker::feed reports what one fact changed, in the order it happened: Progress, then ObjectiveDone, then QuestDone, then QuestOpened.
Happened wraps a fact for the engine, track_facts feeds it to whichever of the tracker and the ledger the game inserted, and each change comes back as a QuestChange written after the frame’s systems and read by them the next frame.
Using it
A status is a registry entry, and these are the two Delve’s caves have.
let fire = damage_kinds.expect("fire");
let statuses = Registry::from_defs(vec![
StatusDef { badge: Some('s'), ..StatusDef::new("scorched").ticks(fire, 1) },
StatusDef { badge: Some('z'), ..StatusDef::new("dazed") },
])
.unwrap();
A fact is the game’s reading of an engine message, which is the only translation quests and counters need.
let mut report = |f: Fact| happened.write(Happened(f));
for d in out.deaths.read() {
if let Ok((kind, faction)) = out.monsters.get(d.entity) {
report(Fact::new(facts.killed).about(kind.0.raw() as u64));
report(Fact::new(facts.killed_faction).about(faction.0.raw() as u64));
}
}
The line
The engine acts on two things a status says and nothing else: it installs and removes the stat modifiers, and it turns the tick into a hit.
Everything richer is the game’s, keyed by the id: a status that silences an ability, one that walls a door, one that turns a body to stone is a system reading StatusEvent or Afflicted and doing the rest.
Whether anything is inflicted at all is the game’s too, and the shape the randomness rule points at is a system reading DamageDealt and writing Afflict with a chance drawn from the game’s own stream, never the engine’s, so a rule a game adds cannot shift the dice of the blows the engine has yet to throw.
The engine also never decides that a status is worth saying out loud: it writes the three events and a game turns the ones about its player into words, which is why every phrase about an affliction lives in a game or in the narrator’s table.
What a panel shows of a status is the same division: badge is the one character the engine offers, and anything else is a Facet the game pushes onto a row in ViewSet::Annotate, a key interned in Facets, the words, and a tone.
A facet is an escape hatch and its use is a signal, so two games pushing the same key is the argument for putting that field in the view instead.
A stat is content, which is the reason nothing in the engine names one: CombatRules is handed the stat it should read as armor, an affix names the stat it sharpens, and a status names the stat it moves.
Facts are the same refusal one level up: nothing in the engine names a fact, a counter or a quest, and the subject and object are opaque numbers the game chose, usually a definition id.
A game therefore writes one system that turns the messages it cares about into facts, and every achievement, generated objective and run summary after that is data over the same stream.
The one thing the engine insists on is when: track_facts runs after the frame’s systems, so a change is read on the next frame and a quest cannot finish halfway through the turn that finished it.
Where it lives
rl-rules is tier 1 and has no Bevy in it, which is what lets the awkward parts be proved without an App.
status.rs holds the stacking rules and the tick, so a status applied three times, extended, and expiring one instance at a time is a test over a Statuses and a Stats built by hand.
stats.rs holds the order of operations and the source tagging, and the test that matters is the one where an item’s modifier, a status’s and a game’s share a number and only the item’s is stripped.
events/ is fact.rs, ledger.rs and quest.rs, and none of the three knows what a fact is about, so a quest chain is tested by feeding facts made of integers.
rl-bevy is tier 2 and thin over both: status.rs is the two components, the two requests and the system that ticks the current map, and events.rs is a message, two optional resources and one system that feeds them.
Thin on purpose, because the parts worth testing are the parts that never needed a world.
Rendering
Drawing is a glyph terminal and three things that write into it: the map from the player’s point of view, what flies over it for a moment, and a screenshot taken with nobody at the keyboard.
A game writes Cells into a back buffer as if it were a console, and the plugin pushes to the screen only the cells whose contents changed, so a turn-based frame where nothing moved costs nothing.
What a tile looks like is the game’s to say; what light, memory, fire and gas do to that look is the engine’s.
Turning it on
RoguelikePlugins adds all four, because a game with no terminal has nothing to draw into and a map with no viewport is a map nobody sees.
TerminalPlugin takes the grid in cells, the pixel size of one cell and the font height, spawns the camera and the cell entities in Startup, and flushes the buffer in PostUpdate.
Glyphs come from the system’s monospace family, which needs Bevy’s system_font_discovery feature; a browser has no font database to search, so on wasm they come from the font Bevy embeds, which covers printable ASCII and nothing else.
MapViewPlugin takes the Rect it draws in, the way every panel does, so a game has no MapView of its own to insert and cannot forget one.
Its finish declares depends_on::<CorePlugin> and depends_on::<FovPlugin>: without field of view no tile is ever seen or remembered, and the map would draw as nothing at all.
It chains follow_player and draw_map in PresentSet::Map, and puts dress_props before draw_map in plain Update rather than in a play-only set, because a prop is put down while a place is built and Added matches for one frame only.
ParticlesPlugin declares depends_on::<MapViewPlugin> and draws after draw_map, so a burst shows through a targeting cursor and under a menu.
It reads cues in PresentSet::Narrate and takes the turns’ hold on entering Playing, but only if the style takes time, so a headless test with an instant style is never made to wait a frame for a fade.
CapturePlugin does nothing unless RL_CAPTURE names a file, which is why it can sit in the group a game adds without asking.
The model
Cell is one character position: a glyph, a foreground fg and a fill bg, with new, on and dimmed to build one.
Terminal is the back buffer, a resource, so any crate’s presenter writes into it: set, put, print, print_on, fill and clear write, get reads back, and every write outside the grid is dropped rather than wrapping or panicking.
Glyph is how an entity is drawn, a ch, an fg and a layer, and on a tile with two things on it the higher layer wins.
TileAppearance is what each tile looks like in full light, indexed by TileId: set and set_varied fill it, lit reads it back, and an id the game never described draws as a magenta question mark so the gap is visible rather than blank.
seen is that look jittered for the cell and the moment, remembered is it jittered as it was seen and then faded, and under colours both of a cell’s colours by the light landing there.
TileAppearance::load reads the same table from RON against a TileRegistry, refusing the file and naming at once every entry for a tile that is not registered, every tile described twice, and every registered tile the file leaves out, so a gap is reported at startup by name rather than found on the map.
Vary is how a tile strays from cell to cell and over time, a brightness spread, a hue spread per channel and a shimmer that drifts; Memory is the brightness, saturation and cool tint a remembered tile keeps; Shading is how light becomes colour, with the level a tile shows its authored colour at, what an unlit but seen tile keeps, and the speeds a flicker and a shimmer move at.
All of it is cosmetic, and the only clock is the frame’s, so a replayed seed plays identically however it is coloured.
FieldAppearance is fire and gas over the tiles they are on: a flame cell and the flare it flickers toward, the haze glyph gas thick enough to hide behind is drawn with, and a tint per GasId that is grey until the game names one.
MapView is a viewport of terminal cells and the world origin drawn at its top-left, with center_on, clamp_to, to_screen and to_world.
clamp_to is why the view never shows void past the edge of a map barely larger than it, and a map smaller than the viewport is centred in it instead, since there is nothing to scroll.
LightOverlay draws each visible tile’s light as a digit, and only where there is a Lighting to read: a game with no lighting plugin sees the map drawn as it always is, since a world with no light to measure has nothing to put in the digit.
Particles is what is playing: play queues an Animation and refuses one that would take no time, is_playing says whether anything is on screen and is_holding whether any of it holds the turns, and skip_held drops what holds them and leaves the rest to fade.
An Animation is a map, a list of steps and whether it holds, and it is left behind when the player leaves that map.
A Beat is a Trail, a glyph flying between two anchors with three cells of fading tail, or a Burst, every anchor lit at once and fading, each cell showing one of a few glyphs picked by position so it reads as embers rather than a stamp.
Beat::frame answers with Sparks, a cell, a glyph, a colour and how far it has faded, and it is handed a resolver for anchors, so the line is redrawn every frame between where the two anchors are now and a flight at someone who walks on bends to follow them.
ParticleStyle is the pace and the look of anything with none of its own: the plain glyph and colour, seconds per cell of flight, seconds a burst takes, and the glyphs a burst picks from.
ParticleStyle::instant takes no time at all, which is what a headless test wants, and takes_time is what the plugin asks before it watches the turns.
play_cues turns the frame’s Cued messages into one animation per actor, that actor’s cues in the order they were written, each holding the turns.
hold_turns keeps the loop stopped exactly while something that holds it is playing and the player does not already hold a turn, and skip_on_key lets a key pressed during the wait drop every hold in the way, run the turns on, and still be read with the player’s turn in hand.
CapturePlugin reads RL_CAPTURE for the path, RL_CAPTURE_KEYS for keys to press first, RL_CAPTURE_FRAMES for the earliest frame, and RL_CAPTURE_AT=hold to shoot a tenth of a second into the first hold rather than after it.
capture::prepare puts the window above the others and unfocused, so a capture never takes the keyboard from whoever is at the machine, and a frame that comes back entirely black is refused with an error rather than saved.
Using it
Turning drawing on is one plugin group, and the rectangle the map gets is the only decision in it.
fn main() -> AppExit {
let mut app = App::new();
// What every game adds: the window and the glyph terminal, the turn
// loop, sight, the map across the whole terminal, and the UI base.
app.add_plugins(RoguelikePlugins::new("Warren", COLS, ROWS))
.insert_resource(Seed(RunSeed(7)))
.add_systems(NewRun, start)
// Once a frame, before the turns: whatever the player pressed becomes
// at most one intent, however many passes the turn loop then runs.
.add_systems(Update, player_input.in_set(EngineSet::Input));
app.run()
}
What the engine cannot supply is the look of a tile, which is a table the game fills or a file it loads.
/// Both colours of every tile, and how much each cell strays from its
/// neighbours, read from `assets/tiles.ron` against the tiles registered
/// above. A tile the file forgets is reported at startup, by name.
fn appearance(&self) -> TileAppearance {
TileAppearance::load(TILES_RON, &self.tiles).unwrap_or_else(|e| panic!("assets/tiles.ron: {e}"))
}
The line
The engine draws; the game says what things look like.
A tile’s colours, a prop’s glyph and an item’s are content, authored per tile or per definition, and the engine only darkens, fades, tints and jitters what it was handed.
That is why nothing here takes a ToneId: Cell and Glyph carry a Color outright, because a green slime is green in every palette and a floor’s brown is not a semantic role.
A ToneId is the other half of the same rule, one layer up: a widget takes a role, Palette holds the colour per role, and a game restyles every panel at once by replacing one resource.
So the boundary falls between a thing and a word about a thing: the map and the entities on it are drawn in their own colours, and everything a panel says about them is drawn in a tone.
A prop is described twice for the same reason, and the second half is here: the engine spawns it with its kind and no glyph, and dress_props gives it the one its definition asks for, so nothing below this crate names a Color.
A prop a game dressed itself keeps what it was given, since the query asks only for those with no glyph.
Fire and gas are drawn only on tiles in sight, because memory holds no smoke, and what hides behind a haze is decided by the map’s opacity rather than by the renderer’s taste.
The engine will not animate a game’s own action: a resolver says what is worth seeing by writing a Cued, and what plays it is a plugin that may not be there.
Without a watcher the cues are written and forgotten and the loop runs as if there were none, which is what a headless game gets and why the same rules run with and without a window.
The pace is the engine’s and the look is the game’s: ParticleStyle is one resource, and a game with a look of its own writes Animations to Particles directly.
The terminal is not a widget toolkit and the map view is not a camera: there is no scene graph, no z-ordering beyond a glyph’s layer, and no interpolation between turns.
One sprite and one text entity per cell is fine at a hundred columns and would be replaced by an instanced grid for a bigger one, and nothing above this crate would notice.
Where it lives
rl-render is tier 2 and sits below rl-ui and above rl-bevy, which is what lets a panel and the map write into one Terminal without either knowing the other.
Keeping the shading in a module of its own makes it plain functions over a colour, a light level and a position, tested with no App and read by no rule.
The loader is separate again, so a game’s colours are a file beside its monsters and a new tile is a line rather than a recompile.
The particles’ arithmetic is Beat::frame and Animation::frame over a time and a resolver, which is why what a flight shows at a given moment is a test rather than a screenshot.
rl-bevy owns the cue and the hold, not the drawing, so a headless game raises the same cues and waits for nothing.
CapturePlugin is its own plugin because it is the one thing here that is not about a player: a game disables it the way Bevy’s groups allow, and a run without the environment variable never notices it.
Panels
A panel is three things and never one: a view, a collector and a presenter. The view is a resource of plain data, the collector is the system that refills it each frame, and the presenter is one way of drawing it. The query is the half worth sharing, because “every actor in the viewshed, nearest first, with a health fraction and a relation” is the same sentence in every roguelike and a gold-ruled rail with small-caps headings is one game’s taste. So a game takes all three, or the first two and draws its own, or neither.
Turning it on
UiPlugin is the base every other plugin here needs, and it draws nothing: it holds Tones, the Palette, Facets, Modals, the direction and cursor bindings and the repeat pace, the Focus and the Sighted list, the Controls registry, and the MessageLog.
The log lives here rather than with the panels that draw it because a game writes to it from its own systems whether or not anything draws it, so a headless test adds this plugin and has a log with no panel in sight.
It chains ViewSet::Sight, Collect, Annotate and Speak inside PresentSet::Narrate, the frame’s work-out-what-to-say phase, and finish declares depends_on::<CorePlugin>.
Each panel after that is its own plugin, constructed with the Rect it draws in and whatever titles and hints it carries, and it adds its view plugin behind it when the game did not.
A strip draws in PresentSet::Chrome and a screen in PresentSet::Overlay, which is what makes a modal cover the thing it is about.
VitalsPanel, NearbyPanel, GearPanel and LogPanel are strips; InspectPanel, AbilityPanel, SheetPanel, OffersPanel, ScrollbackPanel and TargetPanel are screens, and every screen has a modal.
Which of the two layers declares it is not the same for all six, and the rule is that it goes wherever the behaviour is: the two cursors are the view’s, so InspectViewPlugin and TargetViewPlugin declare the modal and the key themselves and a game that takes the view alone still gets a cursor it can open, while AbilityPanel, SheetPanel, ScrollbackPanel and OffersPanel declare theirs in the presenter.
A key that opens a screen is declared in finish, after the game’s own so a controls screen lists the game’s groups first; the offers screen is the one with no key of its own, since it opens on a crowded bump or on the interact key.
LogPanel and ScrollbackPanel have no view plugin at all: both draw MessageLog, one as the last few lines along the map and one as a whole scrollable screen, and neither knows the other exists.
What each collector cannot work without it declares: GearViewPlugin, SheetViewPlugin and InspectViewPlugin need Registries, NearbyViewPlugin and InspectViewPlugin need CombatRules for the relation a row carries, and OffersViewPlugin and OffersPanel depend on props.
What is optional is read as optional: VitalsViewPlugin reads NoiseHeard through reads, so a game with no noise shows no reading rather than failing to start.
The model
Row is one entity as a panel reads it: the entity itself, the label from its Name, its own Glyph, a Chebyshev distance, an optional relation and health, an optional alert, and the facets a game pushed.
The glyph is content rather than theme, which is why a green slime stays green in every palette, and relation and health are optional because a thing on the floor has neither.
Alert is Unaware, Searching or Hunting, three readings and no more because three is what the engine can say without guessing; what each is called is the presenter’s, since one game’s monsters sleep where another’s stand idle.
Bar is a label, a value, a maximum and a tone, and fraction is how full it reads.
A view holds no Color, no Rect and no string the game did not supply, so the same data serves the terminal panels, a game’s own drawing and a test that never opens a window.
A Facet is what the engine cannot know: a key interned in Facets, the words, and a tone.
The engine fills the row in ViewSet::Collect, a game’s system pushes in ViewSet::Annotate, and the presenter prints what it finds in the order it was pushed.
Facets are an escape hatch and their use is a signal: two games pushing the same key means the field belongs in the view.
ToneId is a semantic role interned in Tones, and Palette is the colour per role.
Eleven are interned before anything is authored, text, muted, good, bad, notice, title, frame, surface, select, hit and kill, and their ids are constants so nothing looks them up.
A tone with no colour falls back to text rather than panicking, and every uncoloured tone is named once at OnEnter(Playing), which is visible in a playtest and cannot spam a frame.
readable is what keeps a dark name legible on a dark surface without losing its hue.
Modals is the stack of open screens, innermost last, and empty means the world has input.
modal_is(id) gates a screen’s own systems and no_modal gates the engine’s, which is how a game stops discovering that its player walks while the bag is open.
A stack rather than a return-to slot, because a slot can be pushed twice and lose the first target.
Sighted is refilled twice a frame rather than once, at the head of the input phase and again in ViewSet::Sight, because those are two different moments: the turns run between them, and a list collected before the player’s key was resolved is not the list the panels draw.
InSight reads it as actors nearest first and then things nearest first, which is the order the nearby rail prints.
Focus is the one entity picked out of that list, held by entity rather than by cell so two things on a tile are two stops, and a focus on something that has left sight is treated as none rather than as an error.
The rail highlights it, the look cursor opens on it and an aim opens on it when the aim can take it, so the row picked out and the thing aimed at are one choice.
VitalsView is the player: a label, a list of Bars, armor, status badges, game facets, the turn, the position, whether the player is seen and how loud it has been.
NearbyView is actors and things as Rows with the focused Sighting; GearView is a GearSlot per registered slot in declared order, filled or empty, since what is not worn reads as clearly as what is, with a worn thing’s charges when it holds more than one.
InspectView is where the cursor is, what the ground there is called, whether it burns, what gas hangs there, the Row under it and a Duel fought at the distance the cursor stands from the player.
Its collector builds blows and shots for both sides, packs each pair with Loadout::arms and hands that Chebyshev gap to Combatant::armed, so an actor carrying only a gun reads dangerous across the room and harmless once you are beside it.
A Prop is named and never duelled, since a crate’s health is there to be broken rather than fought, and is_a_threat is what a presenter of a game’s own asks when it wants the forecast only against something the player is at odds with.
AbilityView is an AbilityRow per ability the turn-holder knows, in registration order so a key bound to the third row stays bound to it, each carrying its costs, requirements and effects as sentences and every reason it is refused.
TargetView is what is being aimed, the cursor, the footprint, the flight, what lies beyond it, whether the aim is legal, why not, and a Row per target.
AimAt, AimThrow and AimFire are how a key asks for a cursor, and the cursor writes the Intent itself on confirm.
OffersView is an OfferRow per verb the player is offered where it stands, with its cost in hundredths and the reason a refused one is refused.
SheetView is the character sheet: stats with every Change that made them what they are, resists, strikes, statuses and what is worn.
A presenter reads a view, reads the Palette, and writes cells to the Terminal; it owns no state and makes no decision a game might want made differently.
panel::split_right, split_bottom and split_top hand back both halves of a cut, which is the whole of the engine’s opinion about layout: no resource holds every panel’s rectangle.
clear, frame, section and bar are public so a game taking the view and drawing its own does not rewrite a box-drawing routine, and ListMenu is the selection a screen keeps.
A strip clips a long line and a screen wraps it, because on a strip a cut line is a cut line while on a screen the reader opened in order to read.
Using it
Adding panels is the whole of the cheapest way in: each plugin holds its rectangle, reads a view the engine keeps current, and draws itself.
// Five panels. Each holds its own rectangle, reads a view the engine
// keeps current, and draws itself: none of them needs a system here.
// Warren has no equipment slots, so it takes no `GearPanel`. Opt-in
// is per panel: you add the ones you have a game for.
.add_plugins((
VitalsPanel::new(screen.vitals).heading("Vitals").bars(10),
NearbyPanel::new(screen.nearby).titled("").headings("In sight", "On the floor"),
LogPanel::new(screen.log),
InspectPanel::new(screen.inspect),
// A second presenter over the log the strip already draws: `p`
// opens all of it, scrollable and filterable by tone.
ScrollbackPanel::new(screen.scrollback),
// Every key declared below and by the engine, on one screen, and
// the one hint that opens it in the rail's last row.
ControlsPanel::new(screen.controls).hint(screen.hint),
))
// What the engine cannot know about a row. Named by set, never by
// ordering after a collector function.
.add_systems(Update, (note_bag_and_floor, note_what_a_rat_is_doing).in_set(ViewSet::Annotate))
What the engine cannot know arrives the other way, as a facet pushed onto a row in ViewSet::Annotate.
/// What a rat is up to, on the row the engine built for it.
///
/// `MonsterAIMode` is not a thing the engine has; `flee_at` is this
/// game's rule. So the row gets a facet, in a tone this game declared,
/// and the rail prints it without knowing what fleeing is.
fn note_what_a_rat_is_doing(
mut nearby: ResMut<NearbyView>,
mut facets: ResMut<Facets>,
tones: Res<Tones>,
bestiary: Res<Bestiary>,
rats: Query<(&Kind, &Health)>,
) {
let fleeing = tones.get("fleeing").expect("declared while building");
for row in nearby.actors.iter_mut() {
let Ok((kind, health)) = rats.get(row.entity) else { continue };
if health.current <= bestiary.defs.get(kind.0).flee_at {
row.facets.push(facets.facet("mood", "fleeing").toned(fleeing));
}
}
}
The line
The engine owns the query and the game owns the look, and every escape from the look is cheaper than the one below it: retitle and move the rectangle, swap the palette, keep the view and write the presenter, or add neither plugin.
Opt-in is per panel and not per crate, so a game with no equipment adds no gear panel and nothing in it ever runs.
A widget takes a ToneId and never a Color, because a widget that took a colour is a widget every game forks; there are no literals in a presenter and no colour in a view.
No engine type, doc or constant says weapon, spell or monster: a game’s vocabulary reaches a panel as a Name on an entity or a Facet on a row, and nothing in between learns a word.
The engine will not guess a name, so a game that renames a thing and forgets the Name shows a stale row, which is the honest cost of a component over a trait a game would have to implement to hand back one string.
Arithmetic that is really about the rules is not a panel’s: the inspect panel’s forecast is rl_rules::forecast, resolved through the same mitigation pipeline a real blow goes through, so a duel that reads wrong is a rules bug with a test rather than a drawing bug.
Which of the two attacks that forecast counts is the resolver’s own rule, held in Arms::at: the panel hands over both sets of rolls and the gap the two stand at and picks nothing for itself.
A game’s annotate system names ViewSet::Annotate and never orders itself after a collector function, so the engine may split a collector in two without breaking it.
Views are rebuilt every frame rather than on a turn boundary: a frame already rewrites every cell of the map, and a panel that is one turn stale is the kind of bug that survives to a release.
The engine never opens or closes a modal; a key handler does, and the run conditions read the stack.
What the engine does not get is a widget toolkit: no text entry, no scrollbars, no drag, no focus traversal, and no main menu, settings or key-rebinding screen, because those belong to an application rather than to a roguelike.
The test for whether a panel belongs here is whether it needs engine state to build one: the nearby list needs the viewshed, and a settings screen needs nothing.
A view carries an Entity, which is why views are tier 2 and only their arithmetic is not.
Where it lives
rl-rules is tier 1 and holds the derivations, so expected_damage, blows_to_fell, turns_for and duel are property-tested over plain numbers with no App, and are as usable by a balance report as by a screen.
rl-ui is tier 2 and holds everything that names an Entity: view/ is the data and the collectors, panel/ is one terminal presenter each, and tone.rs, facet.rs, modal.rs and focus.rs are the four small registries they all share.
Keeping the presenters in a module of their own is what lets two of them draw one view, and what lets a game delete all of them and keep the data.
rl-render sits below and owns the surface: a presenter writes Cells into a Terminal, and one that paints over the map asks MapView where a tile sits.
rl-bevy gains nothing from any of this; it fixes PresentSet, and the components a collector reads are the ones the engine already had.
Controls, modals and cursors
A game that checks KeyCode::KeyG in one system and prints “g get” in another holds two copies of one fact, and the copy on screen is the one nobody updates.
So a key is declared once, as a group, an action and the chords that ask for it, and both the game and the controls screen read that one declaration.
Around it sit the two things every game with a screen gets wrong on its own: which screen owns the keyboard, and what a cursor does when you push it at a wall.
Turning it on
UiPlugin is the base, and it holds all of the bindings: the Controls registry, Modals, DirectionKeys, CursorKeys, ControlsKeys, the RepeatPace and the Repeats state.
It initialises ButtonInput<KeyCode> itself, so a headless game with no input plugin still has the resource every reader here takes and reads nothing pressed.
It clears Modals’s frame flags in First, runs forget_keys_on_focus_change before advance_repeats, and advance_repeats before EngineSet::Input so the frame’s repeat is decided before anything reads a key.
close_on_escape runs after EngineSet::Input and before EngineSet::Turns, which is the promise for a screen a game declared and never taught to close.
ControlsPanel takes the Rect it draws in, declares the controls modal, reads its keys in EngineSet::Input, draws the hint in PresentSet::Chrome and the screen in PresentSet::Overlay.
Its finish declares depends_on::<UiPlugin> and adds its own control last, so a game’s groups are listed before the engine’s.
GameMenuPanel takes a Rect that is the most it may occupy rather than the size it will be, declares the menu modal, and draws in PresentSet::Overlay.
Its keys run before EngineSet::Input and outside the engine’s sets, because those sets stop once the run is over and the menu is the screen the run ends on; OnEnter(EngineState::Over) is where it opens itself, with no way back into the run.
It adds the view that screen’s words are drawn from behind it, the way any presenter adds its own, so a game has somewhere to push what it wants the ending to say; Saving and the ending screen is the page for that.
InteractKey is opt-in beside the panels, so a game with no props adds nothing and the key does not exist; its finish declares depends_on::<UiPlugin> and depends_on::<PropsPlugin> and declares its key under the screens heading.
ReplayPlugin adds a recorder when it is given a path and a player when it is given a recording, and ReplayPlugin::from_env reads RL_RECORD and RL_REPLAY for both.
KeyScriptPlugin is a test’s keyboard and lives in rl-bevy: it presses in PreUpdate after Bevy’s input has been cleared, and adds Bevy’s InputPlugin if the app has none.
The model
Chord is a key with Shift held or not, matched exactly, so L and l mean different things without either system checking for the other, and label writes it the way a player reads a keycap.
Keys is what asks for a control: Chords for a list matched in order, Directions { shift } for every key DirectionKeys binds, or Engine for one of the engine’s own bindings.
EngineKey names a binding rather than copying a key, and the chord is read out of CursorKeys, ControlsKeys, MenuKeys or a panel’s own resource every time it is listed, so a game that rebinds a cursor key sees the new key on the screen without telling anyone.
A Control is a group, an action in the game’s words and its keys; Controls::add hands back a ControlId and gives the same id to an identical declaration, so two plugins reading one cursor key list it once.
The registry keeps declaration order, which is the order the screen lists in, with a group placed where its first control was declared.
find, rename and regroup are for a game rewording or moving one the engine declared, and App::add_control is the same thing while the app is built.
ControlInput is what a game’s input system takes in place of ButtonInput<KeyCode>: just_pressed, pressed, which for which of a control’s chords went down, direction and direction_held, label, and input for the rare reading the registry has no word for.
DirectionKeys binds the arrows, hjklyubn and the numpad to the eight directions, because a player who reaches for k and a player who reaches for the numpad are both right; none and bind build another set.
RepeatPace is the wait before a held key repeats and the wait between repeats, one resource for every game since a player who holds a key expects the same walk in each.
Repeats is one state rather than one per control, because the direction keys are one physical set and a player holds one of them at a time, and ControlInput::direction reads a repeat as if the key had been pressed again.
A repeat also asks the turns’ hold for a skip, since the skipper reads a fresh press and a repeat is not one; without that, walking with a key down would wait out every cue in sight.
Bindings borrows the binding resources together and turns a Keys into chords or into a label, writing the direction keys as the families they come in so a walk is one row rather than eight.
Modals is the stack of open screens, innermost last: declare interns a name, open raises one already on the stack rather than listing it twice, close returns to the one under it, close_one takes one out of the middle, and close_all is what an action that ends a turn does.
any_open is true while anything is open and for the rest of the frame a screen closed on, because the key that closed it is still down and the world must not read it as its own.
modal_is gates a screen’s keys on being the one on top, modal_open on being open at all, and no_modal is the one gate a game puts on its own input.
CursorKeys is one set for both cursors, a key to look, one to step to the next thing in sight, one to close and two to confirm, because a player reaching for Enter and a player reaching for Space are both right.
steer is the one reading of a frame’s keys onto a cursor, answering Steer::Close, Confirm, Moved or Stay: close wins over confirm and both over moving, so a frame with several keys down never acts and moves at once.
It takes the candidates as a closure and asks for them only when the cursor moves, since working out what is in sight is the expensive part and most frames press nothing.
Stepping stops at the edge of the window rather than sliding along it, which would read as the cursor moving on its own, and every step or cycle drags the shared focus with it so the row a panel highlights is what the cursor is on.
CursorStyle is Glow or Ticks, each taking a ToneId and an optional second tone to breathe toward, and mark is the one drawing of them, so the two cannot drift apart.
ControlsScreen is which page is showing and ControlsLayout is where the screen and its one hand-typed hint are drawn.
MenuItem is Resume, NewRun, SameSeed or Quit, and the first is offered only while playing; each choice is one message, a Restart or an AppExit, and nothing in the menu knows how a game starts.
Recording is a run written down: the seed, the command line it was run with, and every frame that pressed something as a Pressed of a turn clock, the keys and whether Shift was with them.
KeyScript is the test keyboard: press for one frame, hold until release, which is what a finger resting on a key does.
Using it
A game declares its keys once, under the headings the controls screen groups them by, and keeps the ids.
/// Every key Warren answers to, by name.
///
/// The names are what `player_input` checks; the keys behind them are
/// declared once in `declare_controls`, and the `?` screen lists that
/// same declaration. A key the game stops reading leaves the screen with
/// its declaration.
#[derive(Resource, Clone, Copy)]
struct Binds {
walk: ControlId,
shove: ControlId,
stairs: ControlId,
pick_up: ControlId,
eat: ControlId,
wait: ControlId,
quit: ControlId,
}
/// Declares the keys, under the headings the `?` screen groups them by.
///
/// The walk is every direction key the engine binds, arrows, `hjklyubn`
/// and the numpad; the shove is the same keys with Shift held. A chord is
/// matched exactly, so `L` never reads as a step east.
fn declare_controls(app: &mut App) {
let binds = Binds {
walk: app.add_control("Move", "walk, or strike whoever is there", Keys::Directions { shift: false }),
shove: app.add_control("Move", "shove whoever is there", Keys::Directions { shift: true }),
stairs: app.add_control("Move", "take the stairs", [Chord::key(KeyCode::Enter), Chord::shift(KeyCode::Period), Chord::shift(KeyCode::Comma)]),
pick_up: app.add_control("Act", "pick up what is here", KeyCode::KeyG),
eat: app.add_control("Act", "eat a crust", KeyCode::KeyE),
wait: app.add_control("Act", "wait a turn", [KeyCode::Period, KeyCode::Numpad5]),
quit: app.add_control("Game", "quit", KeyCode::KeyQ),
};
app.insert_resource(binds);
}
The other half is one run condition, which is the whole of what a game has to remember about screens it has not written yet.
// One gate for every screen there is and every screen added later:
// the stack is empty, or the world does not have the keys.
.add_systems(Update, player_input.in_set(EngineSet::Input).run_if(no_modal))
The line
The engine decides the shape of input and the game decides the meaning.
Which chords exist, that a chord matches Shift exactly, that a held key repeats at one pace, that a control has a group and an action and can be listed: all of that is the engine’s, and none of it says what any key does.
What a key asks for is the game’s, and the engine never reads a KeyCode the game bound: it reads a ControlId the game declared, so a game that rebinds a key changes one line and the screen changes with it.
The engine’s own keys go through the same registry, which is what makes the controls screen a list of what the game actually reads rather than a second copy of it to keep in step.
A modal takes the keys by being on top of the stack, not by anything the engine does: the engine never opens or closes a screen, a key handler does, and every system that should not fire while a screen is up asks the stack instead of asking each screen.
A key that only means something inside a screen is that screen’s own, read while it is the top one and written along its bottom border, and it is not declared in the registry, because two as are no clash when one of them can only be pressed inside a modal.
The frame a screen closed on still counts as a frame with a screen up, which is the difference between an Enter that confirms an aim and an Enter that also takes the stairs and spends the turn the aim was for.
ReplayPlugin is what makes “sometimes it gets stuck” into a bug with a reproduction: the loop and every roll are deterministic given the seed, so a run is its seed plus the player’s keys.
What is recorded is what the game reads, after the repeat has been turned into presses, each key stamped with the turn clock it was read at, so nothing in the file depends on how fast the frames came.
A replay never presses while the turns are held for something to be seen, because a key then would skip it and what it skipped decides which clock the next key is read at.
A recording whose clock the game has already passed is a run that drifted, and the replay stops and says which key it reached rather than pressing on into a world that is not the one recorded; that is determinism reporting itself, not enforcing itself.
The half the plugin does not do is the seed: it presses keys and nothing else, so a game that sets RL_REPLAY and leaves its own seed alone replays a recorded run against a fresh world and drifts on the first key.
Restoring it is the game’s, because the seed is inserted before play begins and the plugin has no say in when that happens: rl_bevy::replay::seed answers with the recording’s seed while one is being played, which is what Corsair and Foundry insert, and Delve takes the one line that wraps it, Seed::from_args, which asks the replay first and falls back to a --seed argument or a fresh seed.
KeyScriptPlugin is the same idea at the other end: a key pressed on ButtonInput from outside the schedule is wiped before any system sees it, so a test that wants to press one needs a plugin, and a test’s keys then run the same path a player’s do.
What the engine does not get is a rebinding screen, a mouse, text entry or a menu of settings, because those belong to an application rather than to a roguelike.
Where it lives
All of it is tier 2, because a ModalId gates systems and a control is read out of a Bevy resource, and none of it has arithmetic worth pulling down a tier.
The split that matters is the one inside the crate: the registry, the stack and the cursor reading are each a module with no panel in them, so a game that draws its own controls screen or its own menu still declares its keys once and still gates on the same stack.
steer is a free function over a point, a focus, a frame’s keys and a bounds, which is why a cursor’s behaviour at the edge of a map is a test rather than a playthrough.
The recording’s file lives in rl-bevy and the plugin that writes and reads it in rl-ui, because the file is a seed and a list of keys and the plugin is the one thing that knows what a held key means.
That split is also what lets a game ask rl_bevy::replay::seed and rl_bevy::replay::args before there is an app, and so start a replay on the seed and the flags it was recorded with.
KeyScriptPlugin is in rl-bevy rather than beside the tests that use it, because the engine’s crates and a game’s tests want the same keyboard and nine copies of one was how it started.
Narration
Every game with a log writes the same system: read the blows and the deaths, branch on whether the player did it or had it done to it, pick a tone, push a line. Ten copies of that, each getting the order wrong when two monsters act in one frame, and each narrating blows nobody saw. So narration is a view, a collector and a presenter, the split every panel has, with one twist in where the collector runs: inside the turn rather than inside the frame, because that is the only place the order is knowable.
Turning it on
NarrationViewPlugin keeps the view current and speaks nothing: it registers every message it reads, so a game without the plugin that raises one still has an empty buffer rather than a missing resource, and adds collect_narration to the Turn schedule in TurnSet::Record.
That set is its own phase rather than a reader in TurnSet::React, because a reader there races the game’s own reactions and whichever the executor ran first would decide whether a game’s line landed in this pass or trailed into the next; it is before TurnSet::Cleanup, so the dead still stand where they fell when the row is made.
NarratorPlugin is the presenter, and it adds NarrationViewPlugin if the game has not, so a game that wants the engine’s words adds one plugin and a game that wants the rows adds the other.
It inserts its Phrasebook and runs speak in ViewSet::Speak, the last of the four view phases, which is after ViewSet::Annotate and therefore after the game has had its say about the rows.
Both declare depends_on::<UiPlugin>, which is where the MessageLog the narrator writes into lives.
A game changes a phrase, silences one or asks for the unseen to be spoken by building the plugin: NarratorPlugin::default().phrase(..), .silence(..) and .speaking_the_unseen(), or through the Phrasebook resource afterwards.
The model
NarrationView is the rows a pass produced, oldest first, spoken and cleared once a frame.
A Said is one thing that happened as the narrator reads it: its words, a who, a whom and a what, a named for a registry’s word, a detail, an amount, an at, whether it was seen, whether the doer was who_seen, and the turn.
Words is either a Phrase, one of the engine’s own events, or Own { text, tone }, a game’s line already worded.
Two kinds rather than a phrase a game may add to, because Phrase enumerates what the engine raises and nothing else, and a game’s line needs no entry in a table, only a place in the order.
Phrase is closed for the same reason, and it is split by perspective and by how the damage arrived: YouHit, HitsYou and OthersFight for a blow, YouShoot, ShootsYou and OthersShoot for a shot, each of the six with a twin for the one that got through nothing, so no grammar and no branch on who did it lives in the engine.
What tells a shot from a blow is Reach, which rides DamageEvent down the pipeline to DamageDealt untouched: only Shot is worded as one, and Melee, Thrown and Effect keep the blow’s words, since a bolt or a poison is already narrated by whatever cast or inflicted it.
called is what who, whom and what were called when the row was made, filled in by the collector, because a row is made inside the pass and spoken after it and things change in between: what dies becomes remains and is renamed, what is thrown merges into a stack.
A use is named as one of the thing, You use a stim., since the stack already counts one fewer, and only a use of a thing with a use trigger is said at all: what using anything else means is the game’s to say, the way a crust of bread is.
seen is whether the player saw it, which is either that it happened to the player or that it happened where the player can see, and Phrasebook::speak_unseen decides whether an unseen row is spoken at all.
who_seen is the narrower fact beside it, whether the doer’s own cell was in the player’s sight, and render takes as an argument whether to honour it, since what to do about an unseen doer is the presenter’s setting rather than the row’s business.
A doer that may not be named is UNSEEN, the one word something, and speak names it anyway while speak_unseen is on, because a game that asked for the unseen to be narrated asked for it named.
The two were one field until a shot out of an unlit room named the shooter, a thing the player had never laid eyes on: being shot from the dark is always worth telling you, which is what seen answers, and that answer is not permission to say who fired.
Tell is a game’s own line told in its place among what the turns did: a template, a tone, and up to three entities for its placeholders, with by, to and about to name them.
It is written from inside a pass, usually in TurnSet::React, and the collector reads it with that pass’s events and after them, so it lands in the log below what it answers and above whatever the next actor does.
A line written outside the turns, the one a run opens with or a key refused before any turn is spent, has no pass to wait for and goes to the MessageLog directly.
Phrasebook is the words for every phrase, one entry per phrase with a tone of its own, and set, silence and get are all of it.
The placeholders are {who} and {whom}, which are you or the <Name>, {what}, which is a thing’s name said with its stack’s count, {n} for the amount, {named} for a registry’s word and {detail} for whatever more there is to say, each capitalised by writing the key capitalised.
render fills a template and answers with the text and a Span per name that has a colour, and an unknown key is left in the text as it was written rather than dropped.
speak walks the view, skips what was not seen unless the book says otherwise, looks each phrase up, renders it, and pushes it to the log with its tone and its turn.
The log is a bounded ring of LogEntry, each a text, a tone, a turn, a count and its spans, and a line repeated folds into the one above it with a count rather than filling the panel, because a four-line log filled by one event is a log that has stopped reporting.
A Span is a run of a line’s characters in a colour of its own, which is how a name in the log wears the colour of the thing it names; that colour is content the way a glyph’s is, not a tone, and a presenter keeps it legible against its surface with readable.
ToneId is the other half: a semantic role interned in Tones, coloured by the Palette, so a game recolours every line the narrator will ever speak by replacing one resource.
Using it
The engine’s words are one plugin, and changing any of them is one call on it.
// The engine narrates blows, deaths and pickups into the log, naming
// things in their own colours. Warren changes one phrase: what a rat
// does to you is a bite.
.add_plugins(NarratorPlugin::default().phrase(Phrase::HitsYou, "{Who} bites you for {n}.", Tones::BAD))
A line the engine has no phrase for is a Tell, written from inside the pass it answers.
/// Says in the log that a probe sounds the alarm, for every [`Noticed`]
/// whose observer carries [`Alarm`]: once on the flip from unaware, which
/// is when the engine writes one, and not on every shout after.
pub fn sound_alarm(mut noticed: MessageReader<Noticed>, alarmed: Query<(), With<Alarm>>, mut tell: MessageWriter<Tell>) {
for ev in noticed.read() {
if alarmed.contains(ev.observer) {
tell.write(Tell::new("{Who} sounds an alarm.", Tones::BAD).by(ev.observer));
}
}
}
The line
The engine has no vocabulary of its own, so a line gets its words from exactly two places and neither is a type in an engine crate.
The first is the Phrasebook, a table of English a game may overwrite entry by entry; the default is the English the games spoke before the narrator existed, and nothing reads it but the presenter.
The second is a Name on an entity, which is the game’s word for the thing, reaching the line through a placeholder and wearing the thing’s own colour.
That is why Phrase can be a closed enum without breaking the no-theme-words rule: it enumerates the events the engine raises, YouHit, NoticesYou, YouAreAfflicted, and every one of those is a shape rather than a subject.
A game’s own events are the game’s to narrate, and the engine will not guess: it offers a Tell and the order to put it in, and asks for the words.
The order is the part worth taking, and it is the part a game cannot easily get right: one pass is one actor’s action, so reading a pass’s events in a fixed order gives the true order across a frame of many turns.
A collector in the drawing phase sees a whole frame’s buffers at once and cannot know which blow followed which cast, which is the bug this exists to make impossible.
Within a pass the order is by kind and it is deliberate: a use before the blows it landed, blows before the deaths they caused, and a game’s Tells last, since they answer what the pass did.
The one exception is a notice by whoever holds the turn, which is read first, because that was rolled as the actor looked round before it did anything; a notice by anyone else came of what the turn did and keeps its place after the blows.
What the engine decides about a row is what it can know: who, to whom, with what, how much, where, whether the player was in a position to see it, and whether whoever did it was in sight to be named.
What it cannot know is a game’s line, so Tell carries no tone the engine picked and is always spoken: the game chose to say it, so whether the player saw who it names is the game’s to have weighed.
Between the two sits ViewSet::Annotate, where a game may edit or remove rows before they are spoken, and behind both sits the view itself, which a game may read and speak in its own words with no presenter at all.
What the engine does not get is grammar: no pluralisation of a verb, no agreement, no articles worked out from a name, and no second language.
A template with a placeholder in it is the whole mechanism, because the alternative is a grammar engine that every game would fight.
Where it lives
Both halves are in rl-ui and tier 2, because a row names an Entity and the words are read off components.
The collector and the presenter are one module rather than two, since the twist that makes narration different from a panel is exactly the relation between them, and splitting the file would hide it.
The log is a module of its own, and it is on UiPlugin rather than on either plugin here, because a game writes to it from its own systems whether or not anything narrates or draws: a headless test adds the base plugin and has a log.
MessageLog holds no Color except inside a Span, and a Span’s colour comes off a Glyph, which is content; everything else is a ToneId, which is why the same rows read correctly in a palette the narrator never heard of.
Nothing here is in a tier 1 crate, and nothing needs to be: there is no arithmetic in narration, only order, and order is tested by running a pass.
Saving and the ending screen
A save is a run written down as text, and the engine writes most of it. A game says only what each kind of thing it spawns is, in its own words; where that thing stands, what it carries, what it wears and what is on it are the engine’s, and so are the clock, the queue, the map’s edits and what the player has seen. The end of a run is the other half, and the one state a save is never continued into: the slot is deleted, and what the run was about is drawn on the screen the run ends on rather than filed anywhere. What is saved reaches storage through one trait, so a game in a browser and a game on a desktop differ in how one resource was built and nowhere else.
Turning it on
SavePlugin::new(slot) is the loop around a save, and .version(n) is the number every save is matched against.
It declares needs::<Saves>, the backend, with a hint naming Saves::platform_default("my-game"); it registers PropKind as a saved kind itself, inserts SaveSlot and the Stash, refreshes the stash in Last, and deletes the slot in the EndRun schedule and on entering EngineState::Over.
It registers no key: saving reads the whole world, so a game’s save key is its own exclusive system and calls save_run.
UnloadPlugin is the other half and is added on its own, needing the same Saves: it writes whatever is stashed on the frame the app is told to exit, which is the frame a native window’s close button produces, and in a browser it also installs a listener for the page being hidden or unloaded.
EndingViewPlugin is the third, and it is not a save at all: it holds what a game wants said on the screen a run ends on, needs UiPlugin, and is added by GameMenuPanel itself, so a game drawing its ending with the engine’s menu adds nothing.
A game that wants the sections under a screen of its own adds the plugin alone and reads the view.
Nothing here is on by default, and a game that registers no kinds and adds neither save plugin never reaches storage at all.
The model
Saveable is the one trait a game writes, implemented on the component that marks a kind: capture writes an entity down as Self::Saved, and restore spawns one again from that, nowhere, carrying nothing, at full health.
Neither says anything about position, health, bags, slots, stacks, statuses, remains or where a transition leads, because that is EntityState, the engine’s half of every saved entity, and every field of it is optional, so a kind that gains a bag later still reads an old save.
SaveableState is the same bargain for a resource a game keeps of a run, with capture and restore on the resource itself, which must already exist when the save is restored.
AddSaveable::save_kind::<K> and save_state::<R> register both into SaveRegistry, filing each under the last segment of its type name and panicking at build time when two would share one.
PropKind and Quests are the two the engine implements for itself, since it read those definitions out of a file and can read them again; SavePlugin registers the first, and a game with a quest tracker adds save_state::<Quests>().
RunSave is the result: a format, the EngineSave, a KindSave per kind holding each entry’s own RON, the EntityState of each, and the game’s resources by name.
RunSave::capture walks the kinds in registration order and each kind’s living entities still in play in spawn order, leaving out the dead and the spent the frame keeps only so the log can name them, so one run writes the same bytes whatever order the archetypes are in; restore spawns each kind, binds the ids, puts the engine’s state back on them, restores the game’s resources and then the engine’s own, into a world whose content resources and whose Seed the game has already inserted.
RunSave::state::<R> reads one resource out of a save before anything is restored, for the part of a start that runs before the world exists, and count_of and turn are for a line in the log.
EngineSave is the engine’s half: the run’s seed, the clock, the queue as ids and readings, the map’s edits and its built places, what the player has explored and which sites it has found, each saved entity’s pools and cooldowns, its charges and what refills them, and how many more times each of its triggers may fire, and every burning or gassed cell on every map.
EngineSave::restore puts every one of those back except the seed, which it only carries: a game reads save.engine.seed itself and inserts the Seed before it builds the world the continued run stands in.
Whoever held the turn is out of the queue when a save is taken, so it is put back at the front of the present and dealt first on the run that continues.
An Entity means nothing in another process, so a save numbers entities as SaveId, handed out densely by EntityRemap while capturing and bound to fresh entities while restoring; a queue entry or a bag slot naming an id nobody bound is dropped rather than guessed at.
Saves is the backend as a resource, wrapping an Arc<dyn SaveBackend> of four synchronous methods over named slots, where a missing save is never an error and only real storage trouble is.
FileBackend writes one file per slot under a directory, MemoryBackend keeps them in a map for tests, WebBackend keys them into localStorage, and Saves::platform_default(name) picks the last on wasm and the first everywhere else.
WebBackend and the page-unload listener are the only surface behind #[cfg(target_arch = "wasm32")], beyond the wasm arm inside each platform_default, so a desktop build has no WebBackend to name and a browser build still has a FileBackend with no filesystem under it.
Versioned is the envelope and encode and decode the pair that reads the version before it trusts the rest.
Two numbers are matched, both exactly: SaveSlot::version, which a game bumps when its own kinds change shape, and RunSave::FORMAT, which the engine bumps when the run’s shape does.
save_run encodes the world, writes it to the slot and stashes it; load_run reads the slot back, answering None when nothing was saved there and a SaveError when what is there is not something this build reads.
Stash is the last encoding the game made, behind an Arc<Mutex<_>> so a handler running outside the app holds the same one: stash replaces it, clear forgets it, pending says which slot is waiting, and flush writes it and keeps it, since a browser may send both of its unload events.
refresh_stash re-encodes once per whole turn while playing rather than once a frame, so a window closed on a run loses at most the turn in hand, and forget_save deletes the slot and clears the stash together.
Ending is what the engine knows of an end, inserted on the frame the run is over and gone when the next one begins: the outcome, the run’s seed, the whole turn it ended on, and the game’s epitaph.
EndingView is what the engine cannot know: sections, a Vec of an EndingSection’s heading and body, pushed with EndingView::section in ViewSet::Annotate the way a facet is pushed onto a row.
collect_ending empties it in ViewSet::Collect, so a game pushes on every frame it wants the rows rather than once, and a run begun again shows nothing until something pushes again.
draw_game_menu reads both: the epitaph wrapped, the seed and the turn on one line, then each section’s heading with the lines of its body under it, clipped to the rectangle the menu was given, so a section longer than the screen is cut rather than scrolled.
describe_sheet turns a SheetView into the lines a game pushes when what the player was made of is one of the things it wants said.
Using it
A kind is a component and its account of itself, and a stairway is the smallest one there is: the engine knows where it leads, so the game writes down the glyph and nothing more.
/// A stairway or a cave mouth: the engine knows where it leads, Corsair
/// only how it is drawn.
#[derive(Component, Debug, Clone, Copy)]
pub struct Stairway;
impl Saveable for Stairway {
type Saved = char;
fn capture(world: &World, entity: Entity) -> char {
world.get::<rl_engine::rl_render::Glyph>(entity).map_or('>', |g| g.ch)
}
fn restore(world: &mut World, glyph: &char) -> Entity {
world.spawn((Stairway, crate::places::stair_glyph(*glyph))).id()
}
}
Registering is the step an implementation does not show, and it is the whole of a game’s setup: the plugin at its version, then every kind and every resource.
/// What the save is made of: the plugin that keeps it, the four kinds, and
/// the four resources.
pub fn register(app: &mut App) {
app.add_plugins(SavePlugin::new(SLOT).version(VERSION))
// Items before those who carry them is not required, since every
// kind is spawned before any bag is filled, but it reads better.
.save_kind::<ItemKind>()
.save_kind::<MonsterKind>()
.save_kind::<Captain>()
.save_kind::<Stairway>()
.save_state::<StartOptions>()
.save_state::<Armory>()
.save_state::<Bestiary>()
.save_state::<Entrances>()
.save_state::<Quests>();
}
What a run was about is a section pushed every frame, like any other view: Heist counts the coin in the thief’s bag, which is still there to count whether the thief walked out with it or died on the stairs.
/// The take, on the screen the run ends on however it ended.
///
/// Pushed every frame in `ViewSet::Annotate` rather than once on
/// `RunOver`, because `EndingView` is a view and is cleared and refilled
/// like every other one. The bag is still there to count after the run
/// ends, so the number does not have to be captured at the moment of
/// death.
fn show_the_take(mut view: ResMut<EndingView>, player: Query<Option<&Inventory>, With<Player>>, coins: Query<&Stack, With<Coin>>) {
let Ok(bag) = player.single() else { return };
view.section("The take", format!("{} in coin", take_of(bag, &coins)));
}
The line
With the plugin added and nothing else registered, the engine saves the clock and the queue, the run’s seed, the surface’s edits and every built place, what the player has explored and the sites it has found, the fire and the gas on every map, and what each actor has spent on abilities.
It saves every prop too, because it spawned them from definitions it can read again.
Everything else is the game’s to register, since the engine cannot know what a monster or a sword is made of: what a kind is written down as, and what spawning one again means, are Saveable, and what is not an entity is SaveableState.
The engine then puts its own half back on whatever the game spawned, so giving a kind a bag, a status or a stack later changes nothing about how that game saves it.
What a save guarantees is that the run goes on as the same run: the same things in the same places with the same health, bags, gear and statuses, the same clock reading and the same actor holding the turn.
The seed is the one thing the save writes down and does not put back, and it is the game’s to read: EngineSave::restore carries seed past without inserting a Seed, so a game that builds its world before reading save.engine.seed replays the saved edits onto regions generated from another seed, silently, and Places and streaming’s account of how a region regenerates is what makes that wrong rather than merely different.
What it does not guarantee is that a run replayed from its start would arrive at the saved state, because a save is a position and not a record of the moves: every stream Seeds and determinism describes has been drawn from by the time the save is written, and a continued run draws from those streams afresh.
A save that does not fit is refused rather than repaired: either version failing to match is an error, and so is a save holding a kind or a resource this build does not register.
The one thing the load repairs on its own is content that has gone missing, and only for props: a prop whose definition this build has lost comes back as an empty entity with a warning rather than failing the whole save.
What is left of a run that cannot be continued is a screen and not a file: the slot is deleted when the run ends, and the ending is drawn for as long as the player looks at it.
The outcome, the seed and the turn are the engine’s, since it kept them; every heading and every body is the game’s, because only the game knows whether a run is measured in coin, in decks cleared or in what the player was made of.
Nothing in rl-save decides when a run is over, and nothing in rl-ui writes a byte to storage.
When the stash is refreshed is the engine’s, and so is when the slot is deleted, because SavePlugin schedules forget_save itself; what a game decides is when a run is written down, which is the save_run behind its own key.
So a game that clears the stash has cleared what the way out would have written, which is what makes a death final rather than a suggestion.
Where it lives
rl-save is tier 2 and sits on top of rl-bevy rather than beside it, because what a save is made of is the engine’s own components: an Inventory, an Equipped, an Afflicted, a Transition, a Remains.
What that buys is that each game’s save walk was deleted rather than shared out: what every game used to write by hand is EntityState and EngineSave, and a game’s own save file is its kinds and nothing else.
backend.rs is the only file that knows where bytes go, which is why a browser is one implementation of a four-method trait rather than a second path through the crate.
versioned.rs and remap.rs are plain functions over plain data, remap.rs’s Entity aside, so the version policy and the density and stability of a SaveId are tested as properties with no App anywhere near them.
This is also the one tier 2 crate scripts/check-tiers.sh --wasm builds, because the two wasm-only pieces are invisible to a native build and would otherwise rot unseen.
The ending is in rl-ui because it is a view, a collector and a presenter like every other screen, and nowhere near rl-save.
It was a Morgue there until 2026-09-23: the same sections, rendered and written to a file that nothing read while the game ran, which is how a presenter came to do file I/O and how a UI crate came to depend on the save crate for one screen.
Showing them instead of storing them cost the page a paragraph and rl-ui a dependency.
The overworld
The overworld is one screen: the world graph drawn at region scale, with the regions the player has been in sight of lit and a picker over the landmarks it has found. It is a reading of what the game already holds, and it owns none of it, neither the player’s position nor travel nor the fog it draws. Choosing a site writes a message, and what that message means is the game’s. A game that leaves the plugin out loses a screen and nothing else.
Turning it on
OverworldPlugin is opt-in and takes no arguments.
It declares needs::<OverworldLayout>, the terminal rectangle the map is drawn in, with a hint naming the shape to insert.
It declares the overworld modal, inserts OverworldScreen, BandAppearance, OverworldStyle and OverworldKeys at their defaults, adds PortalRequest as a message, and puts handle_keys in EngineSet::Input and draw_overworld in PresentSet::Overlay.
In finish it asserts that CorePlugin and UiPlugin were added, and lists its own keys on the controls screen under the heading Map, read after the game has finished building so a game that inserted its own OverworldKeys has those listed instead.
BandAppearance is the one table a game has to fill, because a band nobody set is drawn as a magenta ?.
The screen reads WorldRes, WorldMap and Knowledge, so it belongs to a game with a streamed surface: a game of floors alone has no world graph for it to draw.
The model
OverworldScreen is all the state the screen keeps, one selected, an index into the discovered-site list.
Whether the screen is open is deliberately not in it: that is on the shared Modals stack Controls, modals and cursors describes, which is what stops this screen and a game’s own from both believing they own the arrow keys.
overworld_modal(&modals) is its ModalId and panics when the plugin was not added, and overworld_open is the run condition a game gates its own input on.
OverworldKeys is toggle, close, prev, next and go, defaulting to m, escape, the two horizontal arrows and enter.
handle_keys toggles the screen only while it is top of the stack or nothing at all is open, so the map key does not open the map from inside an inventory, and it reads every other key only while the screen is top.
go writes a PortalRequest and closes the screen, and moves nothing itself.
PortalRequest carries one field, site, an index into WorldRes’s sites(), where a Site is a SiteKindId and the region it occupies.
BandAppearance is a table of Cell by BandId, the band a region was classified into, with set and get and that magenta fallback.
OverworldStyle is the colours the screen draws for itself rather than out of the table: rivers, roads, a discovered site, the selected one, the player’s marker, regions never seen, and show_unexplored, which decides whether an untouched region is drawn dimmed or left blank.
OverworldLayout is one Rect of terminal cells.
draw_overworld runs only while the modal is open and writes into the Terminal in PresentSet::Overlay, the last layer of the frame, so it covers the map view and the panels under it.
It centres the viewport on the player’s region when the world is wider than the rectangle, clamped to the world’s edges, and sits on the world’s corner when there is no marker to centre on.
Each cell is the region’s band, then a ~ for a river, a + where a road runs, an O on a discovered site and an @ on the player’s own region, each of those keeping the background of what it replaced.
The marker is derived from the player’s Position through WorldRes::region_of_tile, and is left out altogether when WorldMap::current() is not the surface, because a tile position on another map says nothing about which region it is under.
What the screen knows of the world is Knowledge: region_touched for the fog, and site_discovered and discovered_sites for the list and the markers.
Those are filled by update_viewsheds in FovPlugin, which touches a region and discovers any site in it for every tile a RevealsMap viewer sees while on the surface, so the overworld shows nothing the player has not looked at.
Using it
A game answers a PortalRequest however its own fiction says travel works, and corsair’s answer is a warp to the middle of the chosen site’s region, from wherever the player happens to be.
/// Asks the engine to move the player to a discovered site when the
/// overworld asks, from wherever the player is, a cave included.
fn honour_portals(
mut requests: MessageReader<PortalRequest>,
mut warps: MessageWriter<WarpRequest>,
world: Res<WorldRes>,
mut log: ResMut<MessageLog>,
turns: Res<Turns>,
player: Query<Entity, With<Player>>,
) {
let Ok(entity) = player.single() else { return };
for req in requests.read() {
let Some(site) = world.sites().get(req.site) else { continue };
let target = world.region_tiles(site.position).center();
warps.write(WarpRequest { actor: entity, to: Destination::Surface(target) });
log.push("The portal takes you.", Tones::NOTICE, turns.turn_number());
}
}
The table of how each band looks is the part of turning the screen on that the plugin cannot default, and the numbering in it is the game’s from end to end.
pub fn band_appearance(&self) -> BandAppearance {
let mut look = BandAppearance::new();
look.set(SEA, Cell::new('~', Color::srgb(0.2, 0.35, 0.7)).on(Color::srgb(0.03, 0.08, 0.2)));
look.set(LAKE, Cell::new('=', Color::srgb(0.3, 0.55, 0.9)));
look.set(BEACH, Cell::new(':', Color::srgb(0.85, 0.78, 0.5)));
look.set(GRASS, Cell::new('.', Color::srgb(0.35, 0.65, 0.3)));
look.set(JUNGLE, Cell::new('T', Color::srgb(0.15, 0.5, 0.2)));
look.set(DUNES, Cell::new(',', Color::srgb(0.85, 0.7, 0.4)));
look.set(MANGROVE, Cell::new('"', Color::srgb(0.3, 0.5, 0.4)));
look.set(HILL, Cell::new('n', Color::srgb(0.55, 0.6, 0.35)));
look.set(MOUNTAIN, Cell::new('A', Color::srgb(0.6, 0.55, 0.5)));
look.set(VOLCANO, Cell::new('^', Color::srgb(0.95, 0.95, 1.0)));
look
}
The line
The overworld is for looking: a picture of the world graph at region scale, and a list of the places the player has found in it.
It is not travel, not a second map and not a record of anything.
It writes no Position, switches no map and asks for no chunk; the one thing it writes is a PortalRequest, and a game that reads no such message is left with a screen that draws and does nothing.
That is the seam with places and streaming: Places and streaming owns which map is current, which window of regions is loaded and what a warp does, and the overworld reads the outcome rather than taking any part in it.
The regions the screen draws are the same regions the window is measured in, and drawing one neither loads it nor keeps it loaded, since a band, a river and a road belong to the world graph and are known without generating a tile.
So the screen shows the whole world while the game holds a few regions of it, which is the reason to have it at all.
The engine decides which keys the screen answers, that it answers them only while it is the top modal, and that choosing a site is a request rather than a move.
The game decides what a band looks like, what a site is, what a portal costs, whether it is refused, and whether there is a portal to ask for.
The fog is the engine’s, but it is filled by sight rather than by this screen: a region is touched when a revealer sees a tile in it and a site is discovered the same way, so the map is a record of where the player has been rather than something handed out at the start.
Colours here are a Color in a resource rather than a ToneId in a palette, unlike the widgets Panels covers, because this is a screen of its own and not a widget a game composes; a game that wants other colours replaces OverworldStyle.
Where it lives
rl-overworld is tier 2 and a crate of its own rather than a module of rl-ui, because nothing depends on it: the screen is one resource of state, four tables and two systems, and dropping the plugin costs a game no other line.
Being separate is what keeps rl-ui clear of rl-world as well, since a panel crate that knew what a river was would be one that a game with no surface still paid for.
What the split buys downwards is that everything drawn here is already tested below it: bands, rivers, roads and sites are rl-world’s and are properties of a generated WorldGraph checked over a range of seeds with no App, and the fog is Knowledge’s, round-tripped through a save.
What is left in this crate is the frame-shaped part: which modal is on top, where the viewport is centred, and which glyph each cell ends up with.