The tutorial · Step 2

A level on the board

Play this step Its whole project

By the end of this step, pressing Confirm on the title opens a real level:

The board: First push at the top left, a walled room in the middle with the player as an at sign, a box in square brackets and a target in angle brackets, and the help line at the bottom.

You can't move yet. This step is about three questions every game has to answer: where does a level come from, how does the game hold it in memory, and how does it get onto the screen? In Shuyi, each of those has its own file, and keeping them apart is most of what makes a game easy to change.

FileIs it new?What it does
crates/game/files/levels/1.mapNewThe level, as text
crates/game/src/level.rsNewReads that text
crates/game/src/rules.rsNewWhat can change in the puzzle
crates/game/src/board.rsNewThe screen that shows it
crates/game/src/stages.rsChangedTells the title to start the board
crates/game/src/lib.rsChangedNames the three new modules
crates/game/files/palette.ron, en.ronChangedThe new colors and words
Cargo.toml, crates/game/Cargo.tomlChangedAdds serde
crates/game/tests/board.rsNewA test of the board

Your project should end up like this step's copy.

The level's file

Game idea: keep a game's data apart from its code. If a level is a text file, then changing one never means changing code, and a level designer needs no programmer. It also means a player could make their own.

Make the folder crates/game/files/levels/, and in it the file 1.map:

// Level 1, in the engine's `.map` format.
(
    name: "First push",
    legend: { '.': "floor", '#': "wall", 'o': "target" },
    features: { (2, 2): Start, (3, 2): Box },
)
---
#######
#.....#
#....o#
#.....#
#######

This is the engine's .map format. Below the --- line, each character is a tile, one square of the level. Above it, the header says what those characters mean. legend says that . is floor, # is wall, and o is a target, a square a box should end up on.

features says what else stands where. Tiles are counted like cells: (x, y), from the top left, starting at 0. So (2, 2): Start is the third column of the third row, where the player begins, and (3, 2): Box is the square right beside them.

This is so readable that you can already play with it. Here is the room made twice as wide and twice as tall:

In crates/game/files/levels/1.map:

- #######
- #.....#
- #....o#
- #.....#
- #######
+ ##############
+ #............#
+ #....o.......#
+ #............#
+ #............#
+ #............#
+ #............#
+ #............#
+ #............#
+ ##############
The room is seven tiles wide and five tall.
Before
The room is fourteen tiles wide and ten tall, twice the size each way, with the player and the box still near its top left.
After

Once you've finished this step, try that, then put it back. Doing it right now would give you an error, since nothing reads this file yet.

Getting ready: serde

The rules' state has to be able to turn into text and back, for saves and replays. Rust has a well-known library for that called serde. Add it to your game's dependencies. In the top Cargo.toml, under [workspace.dependencies], add the line serde = { version = "1", features = ["derive"] }, and in crates/game/Cargo.toml, under [dependencies], add serde.workspace = true.

Then, in crates/game/src/lib.rs, name the three files you're about to write:

pub mod app_info;
pub mod board;
pub mod level;
pub mod rules;
pub mod stages;
pub mod title;

(Your game won't compile again until all three exist. That's fine: you'll finish them all before you run it.)

Reading it: level.rs

Make crates/game/src/level.rs. It starts by saying what it uses:

use serde::{Deserialize, Serialize};
use shuyi::assets::Formats;
use shuyi::assets::grid_map::{self, GridMap, MapFeature, MapFormat, MapText};
use shuyi::core::TerrainId;
use shuyi::prelude::*;

use crate::rules::Puzzle;

Those { } in a use take several names from one place at once. Then, give each kind of ground a name, and each kind of thing that stands on it:

/// Floor: the player and the boxes go over it.
pub const FLOOR: TerrainId = TerrainId(0);
/// A wall: nothing goes through it.
pub const WALL: TerrainId = TerrainId(1);
/// A target: floor a box should end on.
pub const TARGET: TerrainId = TerrainId(2);

/// Each terrain's id in a level's legend, by [`TerrainId`].
pub const TERRAIN_NAMES: [&str; 3] = ["floor", "wall", "target"];

/// The terrain a legend's id names.
pub fn terrain_named(name: &str) -> Option<TerrainId> {
    let index = TERRAIN_NAMES.iter().position(|&n| n == name)?;
    u16::try_from(index).ok().map(TerrainId)
}

/// What a tile may hold besides its terrain.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Feature {
    /// The player starts here.
    Start,
    /// A box starts here.
    Box,
}

impl MapFeature for Feature {}

Rust: const makes a fixed value. TerrainId(0) is a tuple struct: a struct whose fields have no names, here just wrapping a number, so that a terrain can never be mixed up with some other number. [&str; 3] is an array of exactly three pieces of text, and &str is borrowed text, as opposed to the owned String.

pub fn terrain_named(name: &str) -> Option<TerrainId> turns a word from the legend into a terrain, or None if there's no such word. The ? at the end of the position line says: "if this is None, stop here and return None; if it's Some, carry on with what's inside". ? is how Rust passes a problem up to whoever called, in a single character, and you'll use it constantly.

enum Feature lists all the things a tile may hold, Start or Box. Enums are how you say "one of these, and nothing else", and you'll write a lot of them.

impl MapFeature for Feature {} promises the engine's .map format that Feature is a thing a tile can hold. The trait asks for no functions of its own, so the {} is empty. And the derive line now has Serialize, Deserialize: those come from serde, and let this enum be written out and read back.

Next comes the reading itself:

/// A level, as its file says.
pub type Level = GridMap<TerrainId, Feature>;

/// The format of a level: one tile look, at most 20 tiles a side, which
/// the screen shows whole.
pub const FORMAT: MapFormat<'static> = MapFormat::new(&[grid_map::DEFAULT_TILES], 20);

/// The formats the game reads its own files with.
pub fn formats() -> Formats {
    let mut formats = Formats::standard();
    // The first format of its kind: never refused.
    let _ = formats.register(MapText::<TerrainId, Feature>::new(FORMAT, terrain_named));
    formats
}

/// Reads the level in file `name`, which must make a puzzle.
pub fn read(bundle: &Bundle, name: &str) -> Result<Level, Vec<ContentError>> {
    let level = formats().load::<Level>(bundle, name)?;
    Puzzle::new(&level).map_err(|e| vec![ContentError::new(bundle.display_path(name), e)])?;
    Ok(level)
}

Shuyi: the format registry. Everything a game reads from a file goes through three parts. A kind is what you want (here Level). A format knows how to read one kind from one sort of file (MapText reads .map). And the registry, Formats, holds all the formats, and finds the right one for a file. Your code just says "load me a Level from this file", and never cares what's in the file. So a game could switch to a different level editor later, and only the format changes.

The bundle is your game's files, the ones include_dir! put inside it.

Rust: Result<Level, Vec<ContentError>> is the other type you'll see everywhere. Option says "maybe a value". Result says "a value, or what went wrong": it's either Ok(value) or Err(problem). The ? works on these too, passing an Err up to the caller.

load::<Level> fills in a generic by hand, to say which kind you want, a form of writing people call the "turbofish". let _ = ... means "I know this gives something back, and I don't need it".

If a level makes no puzzle, say there's no start, read reports it as a problem with that file, along with every other problem, as the game loads. So you hear about a bad level straight away, with its file name, and not when a player reaches it. Try it after this step: remove (2, 2): Start, from the level, and run cargo shuyi check.

What can change: rules.rs

Here's the most important file of your game. Make crates/game/src/rules.rs:

use std::collections::BTreeSet;

use serde::{Deserialize, Serialize};
use shuyi::core::{Grid, Pos, TerrainId};

use crate::level::{Feature, Level, TARGET};
/// The puzzle: its tiles, where the player stands and where the boxes are.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Puzzle {
    /// The tiles, which never change.
    tiles: Grid<TerrainId>,
    /// Where the player stands.
    player: Pos,
    /// Where the boxes are.
    boxes: BTreeSet<Pos>,
}

Game idea: the state is everything that's true right now, and could change. In this puzzle, that's only where the player and the boxes are. The walls never move, so the state just keeps a copy of the tiles. Whatever isn't in the state doesn't exist as far as the rules are concerned.

Look at the top of the file: it uses no screens, no keys, no colors. That's on purpose. In step 3, you'll see why this matters so much.

Rust: Pos is a position, an x and a y, and Grid<TerrainId> is the engine's two-dimensional grid of terrains. BTreeSet<Pos> is a set: a collection with no duplicates, which is just what boxes are, since no two share a tile.

Why BTreeSet, and not the better-known HashSet? A BTreeSet always keeps its items in the same order, while a HashSet's order differs from one run to the next. Shuyi's rules must do exactly the same thing every time, on every computer. You'll see what that buys you in step 5 and step 9. So in rules, always use BTree.

Now to build a Puzzle from a level:

    /// The puzzle `level` lays out: it needs one start, a box, and as many
    /// targets as boxes.
    pub fn new(level: &Level) -> Result<Self, String> {
        let mut player = None;
        let mut boxes = BTreeSet::new();
        for (&pos, feature) in &level.features {
            match feature {
                Feature::Start if player.is_some() => return Err("two starts".to_owned()),
                Feature::Start => player = Some(pos),
                Feature::Box => {
                    boxes.insert(pos);
                }
            }
        }
        let player = player.ok_or("no start")?;
        let puzzle = Self {
            tiles: level.tiles.clone(),
            player,
            boxes,
        };
        let targets = puzzle.targets().len();
        if puzzle.boxes.is_empty() || puzzle.boxes.len() != targets {
            let boxes = puzzle.boxes.len();
            return Err(format!("{boxes} boxes and {targets} targets"));
        }
        Ok(puzzle)
    }

This function goes through the level's features, and checks that they make a puzzle. Reading it will show you a lot of Rust at once:

Rust: Self means "the type this impl is for". Puzzle::new is the usual way of writing a function that makes a new one: new isn't special, it's just a habit.

for (&pos, feature) in &level.features is a loop, running once for each feature, and calling its parts pos and feature each time.

match is like an if for all the possibilities of a value. It tries each arm from the top, and the compiler refuses to compile a match that misses a possibility. This is one of the best things about Rust: add a new kind of Feature later, and the compiler will take you to every match that needs to think about it. Feature::Start if player.is_some() is an arm with an extra condition.

let player = player.ok_or("no start")?; turns the Option into a Result: a None becomes the error "no start", which ? returns.

And then a few small functions that let others read the state:

    /// The tiles.
    pub fn tiles(&self) -> &Grid<TerrainId> {
        &self.tiles
    }

    /// Where the player stands.
    pub fn player(&self) -> Pos {
        self.player
    }

    /// Where the boxes are.
    pub fn boxes(&self) -> &BTreeSet<Pos> {
        &self.boxes
    }
    /// Every target tile.
    pub fn targets(&self) -> BTreeSet<Pos> {
        let (width, height) = (self.tiles.width(), self.tiles.height());
        (0..i32::from(height))
            .flat_map(|y| (0..i32::from(width)).map(move |x| Pos::new(x, y)))
            .filter(|&pos| self.tiles.get(pos) == Some(&TARGET))
            .collect()
    }

Notice that tiles, player and boxes in the struct aren't pub, so no other file can change them. These functions let others look, and only look. That's how it stays true that only the rules can change the rules' state.

Drawing it: board.rs

In step 1, you met the idea that graphics are a skin. Now you'll use it. Make crates/game/src/board.rs:

use std::any::Any;

use shuyi::core::Pos;
use shuyi::core::TerrainId;
use shuyi::prelude::*;
use shuyi::ui::frame::Palette;
use shuyi::ui::grid::{
    Camera, GlyphSkin, Grid, GridSkin, Looks, MapColors, MapScene, MapUnit, TerrainGlyphs,
    Terrains, UnitPainter, WalkSpeeds, glyph,
};

use crate::Data;
use crate::level::TERRAIN_NAMES;
use crate::rules::Puzzle;

First, how much of the frame the level may use. A Rect is a rectangle: left, top, width, height. This one leaves the top and bottom rows free for text:

/// The cells a level is shown in: all but the top and bottom rows.
pub const AREA: Rect = Rect::new(0, 1, 48, 22);

What a terrain looks like

/// Each terrain's two glyphs and colors, by [`TerrainId`]: floor, wall,
/// target.
pub const TERRAIN_LOOKS: [([char; 2], &str, &str); 3] = [
    ([' ', ' '], "floor_fg", "floor_bg"),
    (['▓', '▓'], "wall_fg", "wall_bg"),
    (['<', '>'], "target_fg", "floor_bg"),
];

Each row here is one terrain, in the order level.rs numbered them. Each tile takes up two cells, side by side, because a cell is twice as tall as it is wide, so two make a square. Floor is two spaces, a wall is two blocks, and a target is <>. Then come two names from the palette, for the text color and the background.

Try changing a wall's look. Only the first part of the row changes:

In crates/game/src/board.rs:

- (['▓', '▓'], "wall_fg", "wall_bg"),
+ (['+', '+'], "wall_fg", "wall_bg"),
The walls are solid shaded blocks.
Before
The walls are drawn with plus signs.
After

To paint the terrains, the engine asks about them through a trait:

/// The terrains as the skin sees them.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Terrain;

impl Terrains for Terrain {
    fn name(&self, id: TerrainId) -> Option<&str> {
        TERRAIN_NAMES.get(usize::from(id.0)).copied()
    }

    fn glyphs(&self, id: TerrainId) -> Option<TerrainGlyphs<'_>> {
        let &(glyphs, fg, bg) = TERRAIN_LOOKS.get(usize::from(id.0))?;
        Some(TerrainGlyphs::new(glyphs, fg, bg))
    }
}

TERRAIN_LOOKS.get(...) gives an Option, since there might be no such row, and ? handles that. usize::from(id.0) takes the number from inside the TerrainId (.0 means the first field of a tuple struct), and turns it into the kind of number used for counting positions in a list, usize.

The skin

Shuyi: the grid kit (shuyi::ui::grid) paints maps for you. You build a scene, plain data saying which terrain and which pieces are where, and a skin paints it. A GlyphSkin paints with characters, and a sprite skin paints with pictures. They take the same scene. So your rules and your scene are already ready for art.

/// The glyph skin: two cells a tile.
// check-text: not player text, the palette's names
pub const GLYPHS: GlyphSkin = GlyphSkin::new(
    MapColors::new("path", "cursor", "threat"),
    WalkSpeeds::of(8.0),
    0,
);

This names the palette's colors the skin uses for things this game won't draw (a path, a cursor, a threat), and how fast a piece glides. 8.0 is a float, a number with a fractional part. Floats are fine here, as this is only how something looks. They are never allowed in the rules: floats can round differently on different computers, and rules must be the same everywhere.

The pieces

Now the things that stand on the tiles. Each is described as data:

/// Which piece.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Who {
    /// The player.
    Player,
    /// The box with this number.
    Box(usize),
}

/// A piece on the map, as the scene holds it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Piece {
    /// Which piece.
    pub who: Who,
    /// The tile it is on.
    pub pos: Pos,
    /// Whether it is a box on a target.
    pub home: bool,
}

impl MapUnit for Piece {
    type Id = Who;

    fn id(&self) -> Who {
        self.who
    }

    fn pos(&self) -> Pos {
        self.pos
    }

    fn text(&self) -> String {
        let letter = if self.who == Who::Player { '@' } else { '#' };
        format!("{letter} ({},{})", self.pos.x, self.pos.y)
    }
}

An enum's possibilities can hold data: Who::Box(usize) is a box with a number. And MapUnit is the trait that tells the kit what it needs to know about a piece. (type Id = Who; fills in a blank the trait left, like a generic.)

Then the code that paints the pieces:

/// The pieces under the glyph skin: the player's letter over its tile, and
/// a box over both of its tile's cells.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Pieces<'a> {
    /// The palette the colors are named in.
    pub palette: &'a Palette,
}

impl UnitPainter<Piece> for Pieces<'_> {
    fn paint(&self, scene: &MapScene<Piece>, grid: &Grid, buf: &mut GlyphBuffer) {
        for piece in &scene.units {
            let (glyphs, color) = match (piece.who, piece.home) {
                (Who::Player, _) => (['@', ' '], "player"),
                (Who::Box(_), false) => (['[', ']'], "box"),
                (Who::Box(_), true) => (['[', ']'], "box_home"),
            };
            let fg = self
                .palette
                .lookup(color)
                .unwrap_or_else(|| self.palette.engine(EngineColor::Text));
            let Some((x, y)) = glyph::cell_of(grid, piece.pos) else {
                continue;
            };
            for (x, glyph) in (x..).zip(glyphs) {
                if glyph != ' '
                    && let Some(&cell) = buf.get(x, y)
                {
                    buf.set(x, y, Cell { glyph, fg, ..cell });
                }
            }
        }
    }
}

It takes each piece, picks its characters and its color, finds its cell with glyph::cell_of, and writes the characters there. Cell { glyph, fg, ..cell } makes a new cell with this character and text color, and everything else, like its background, from the old one.

Rust: the 'a in Pieces<'a> is a lifetime. A struct that holds a borrow (the &) has to say so, so the compiler can check the borrowed thing lives long enough. You'll seldom need to write these yourself: when you do, the compiler tells you what to put.

So the player's character is one small piece of data:

In crates/game/src/board.rs:

- (Who::Player, _) => (['@', ' '], "player"),
+ (Who::Player, _) => (['P', ' '], "player"),
The player is an at sign.
Before
The player is the letter P.
After

The screen

The screen holds the state, and the name to show:

/// What the lines round the map show.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoardView {
    /// The level's name.
    pub name: String,
    /// The help line.
    pub help: String,
}
/// The board screen.
#[derive(Debug)]
pub struct Board {
    /// The level's name.
    name: String,
    /// The rules' state.
    pub puzzle: Puzzle,
}
    /// The board on the game's level.
    ///
    /// # Panics
    ///
    /// If the level makes no puzzle, which loading it rules out.
    pub fn new(ctx: &Ctx<Data>) -> Self {
        let file = &ctx.game.own.level;
        let puzzle = Puzzle::new(file).unwrap_or_else(|e| panic!("{e}"));
        Self {
            name: file.name.clone(),
            puzzle,
        }
    }

Starting it reads the level from the game's data, and builds the puzzle. unwrap_or_else(|e| panic!("{e}")) says: if this is an error, panic, stop the program with that message. A panic is for things that can't happen if the game is right. It can't happen here, as read already checked this level when the game loaded. The comment says so.

Then it builds its scene:

    /// What the map shows, in a view of `view` tiles round the player.
    pub fn scene(&self, view: (i32, i32)) -> MapScene<Piece> {
        let tiles = self.puzzle.tiles();
        let player = self.puzzle.player();
        let camera = Camera::centred_on(player, tiles.width(), tiles.height(), view);
        let mut scene = MapScene::new(camera.origin, view);
        scene.set_terrain(|pos| tiles.get(pos).copied());
        let targets = self.puzzle.targets();
        for (n, &pos) in self.puzzle.boxes().iter().enumerate() {
            let home = targets.contains(&pos);
            let who = Who::Box(n);
            scene.push_unit(Piece { who, pos, home });
        }
        let (who, pos, home) = (Who::Player, player, false);
        scene.push_unit(Piece { who, pos, home });
        scene
    }

The scene is built new every frame, from the state. That's a good habit: there's only one copy of what's true, so what's on screen can never disagree with it. The camera picks which part of the level is in view, here centred on the player.

    pub fn view(&self, ctx: &Ctx<Data>) -> BoardView {
        let help = "board.help";
        BoardView {
            name: self.name.clone(),
            help: ctx.text_with(help, &[]),
        }
    }
    /// The tiles the glyph skin fits in [`AREA`].
    pub fn view_tiles() -> (i32, i32) {
        GridSkin::<Piece>::view_tiles(&GLYPHS, AREA)
    }

The screen's jobs are the ones you saw on the title. For now, its only action is Cancel, which goes back to the title with Transition::Pop:

impl Screen<Data> for Board {
    // check-text: not player text, the screen's id
    fn name(&self) -> &'static str {
        "board"
    }

    /// Cancel to go back.
    fn actions(&self) -> Option<Vec<UiAction>> {
        Some(vec![UiAction::Cancel])
    }

    fn update(&mut self, _: &mut Ctx<Data>, input: &FrameInput<UiAction>) -> Transition<Data> {
        if input.actions.contains(&UiAction::Cancel) {
            return Transition::Pop;
        }
        Transition::None
    }

And draw puts it all together:

    fn draw(&self, ctx: &Ctx<Data>, buf: &mut GlyphBuffer) {
        let palette = &ctx.palette;
        let c = |e| palette.engine(e);
        let bg = c(EngineColor::Panel);
        buf.fill_rect(buf.bounds(), Cell::new(' ', bg, bg));
        let view = self.view(ctx);
        buf.print(1, 0, &view.name, c(EngineColor::Highlight), bg);
        let last = i32::from(buf.height()) - 1;
        buf.print(1, last, &view.help, c(EngineColor::TextDim), bg);
        let looks = Looks::new(palette, &Terrain, &ctx.game.own.lights);
        let scene = self.scene(Self::view_tiles());
        GLYPHS.paint(&looks, &scene, AREA, &Pieces { palette }, buf);
    }

print(1, 0, ...) prints the level's name at the top left, and the help goes on the last row. GLYPHS.paint(...) then paints the scene in AREA.

The very last function of the file is what lets a test find this exact screen on the stack, by asking "are you a Board?". Every screen you want a test to look inside needs it:

    fn as_any(&self) -> Option<&dyn Any> {
        Some(self)
    }

Close the impl with a final }.

Joining it up

crates/game/src/stages.rs is the file that was empty in step 1. It now names your stage, reads your own data, and answers the title's question of what comes first. Replace the whole file with this:

//! The game's one stage: the puzzle. The title ([`crate::title`]) starts
//! it, and its part of the game's data is [`Own`].

use shuyi::assets::lighting::Lighting;
use shuyi::prelude::*;

use crate::Data;
use crate::board::Board;
use crate::level::{self, Level};

/// The stages' ids, in order.
pub fn names() -> &'static [&'static str] {
    &["board"]
}

/// The game's own part of its data.
#[derive(Debug)]
pub struct Own {
    /// The level.
    pub level: Level,
    /// The lights a level may be in: none, so the default, which changes
    /// nothing.
    pub lights: Lighting,
}

/// Reads the game's own part from its files.
pub fn own(bundle: &Bundle) -> Result<Own, Vec<ContentError>> {
    Ok(Own {
        // check-text: not player text, a file name
        level: level::read(bundle, "levels/1.map")?,
        lights: Lighting::default(),
    })
}

/// What the title starts: the board.
pub fn first(ctx: &mut Ctx<Data>) -> Option<Box<dyn Screen<Data>>> {
    Some(Box::new(Board::new(ctx)))
}

/// Every screen the game can show past the title, for the check that its
/// controls fit each target.
pub fn screens(ctx: &mut Ctx<Data>) -> Vec<Box<dyn Screen<Data>>> {
    vec![Box::new(Board::new(ctx))]
}

/// The game's tools: the engine's, for a game on the short path.
#[cfg(feature = "tools")]
pub fn tools() -> shuyi_cli::BasicTools<UiAction, Own> {
    shuyi_cli::BasicTools::new(crate::game())
}

Shuyi: Own is the game's own part of its data, which BasicData carries for you alongside the words and the keys, in ctx.game.own. And game() in lib.rs already passes stages::own to BasicGame::with_own. That's why step 1 could say Own = (), a value that holds nothing, and you could change it here with no other change.

Rust: Box<dyn Screen<Data>> means "a box holding some type that implements Screen, and which type isn't known until the game runs". That is how a stack can hold a title, a board and a menu all at once. You'll keep seeing it, and you can read it as "any screen".

Now the words and the colors it asked for. In files/en.ron, add this before the last }:

    // The board.
    "board.help": "{Cancel} back",

and in files/palette.ron add the game's own colors:

    // The game's.
    "floor_fg": "#3a3a46",
    "floor_bg": "#16161c",
    "wall_fg": "#5a5a6e",
    "wall_bg": "#26262e",
    "target_fg": "#ffd75a",
    "player": "#ffffff",
    "box": "#c89a5a",
    "box_home": "#a0e07a",
    "path": "#5ac8ff",
    "cursor": "#ffd75a",
    "threat": "#ff7a5a",

Run it

cargo run

If the compiler has complaints, read them from the top down, as one mistake can cause many later messages. Fix the first and run it again. Compare with this step's copy when you're stuck.

Press Enter on the title, and you'll see the picture from the top of this page. Note that the title now shows a line saying how to play: it appeared because stages::names() isn't empty any more. Backspace goes back. That's Transition::Pop, taking the board card off the stack, and showing the title that was underneath, unchanged.

Colors work like they did before, for your own colors as much as the engine's:

In crates/game/files/palette.ron:

- "box": "#c89a5a",
+ "box": "#5a9bff",
The box is orange-brown.
Before
The box is blue.
After

A test

Make crates/game/tests/board.rs. Each file in tests/ is a separate little program:

//! The puzzle played through the engine's Harness: tests of what happened
//! read the board's state. Key names here are the Harness's test-script
//! names, input to a test, not game code.

use game::board::Board;
use shuyi::platform::harness::Harness;

/// The game on its first level.
fn on_board() -> Harness<game::Data> {
    let mut h = game::game().harness();
    h.keys("Enter");
    assert_eq!(h.screens(), ["title", "board"]);
    h
}

fn board(h: &Harness<game::Data>) -> &Board {
    h.screen::<Board>()
        .unwrap_or_else(|| panic!("no board: {:?}", h.screens()))
}

#[test]
fn the_level_is_drawn_and_cancel_goes_back() {
    let mut h = on_board();
    assert!(h.snapshot().contains("First push"), "{}", h.snapshot());
    assert_eq!(board(&h).puzzle.boxes().len(), 1);
    h.keys("Backspace");
    assert!(h.screen::<Board>().is_none());
}

on_board and board are two helpers, used by every test from now on. This test checks that the level's name is on screen, that the puzzle has one box, and that Cancel closes the board. Run cargo test --workspace.

Notice that it reads the state (puzzle.boxes()) and not the picture. Tests of what happened read the state. So you could repaint the entire game, and this test would never notice.

What you learned

For another take on the same ideas, chapter 5 explains the registry, and chapter 4 the skins. Next, you'll walk.