The book · Chapter 6

Rules as commands

Most of what's hard about a game comes later: undo, saving a game in progress, a replay of how someone got that bug, a bot that plays it, a test that doesn't need a screen, online play. Each of those needs to rebuild or repeat the game, exactly.

You get all of them from one rule, if you keep it from the start. A game's rules are a state, everything that's true right now and could change, and that state changes only through commands. A command is plain data saying what a player asks for: "plant a seed here". The same state and the same command always give the same events and the same new state, on every machine, which is called being deterministic. That is what replays, bug reports, bots, undo and online play all rest on.

The chain is short, and worth learning by heart. A key press becomes an action (the keymap, chapter 3), an action becomes a command (the screen, chapter 2), and a command changes the state (the rules, this chapter), which the screen then shows.

A turn game sends a command for each thing a player does. A real-time game sends its inputs once per fixed step instead, 60 steps a second, counted from whole nanoseconds so every machine steps alike. This chapter is about turn games, until Rules in fixed steps near its end.

The state and its commands

The garden's rules know nothing of screens, keys or the clock:

/// The garden: every plot, planted or not, where the player stands, the
/// seeds left, and its luck. Everything the rules read is here, so the
/// same state and the same command always give the same answer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Garden {
    /// Each plot: whether something grows in it.
    plots: Vec<bool>,
    /// The plot the player stands at.
    at: usize,
    /// The seeds left.
    seeds: u32,
    /// How often a seed grows, in percent.
    grow_percent: u8,
    /// The garden's luck: the same seed, the same luck.
    rng: SimRng,
    /// The commands applied so far.
    turn: u32,
}

/// What the player asks for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Command {
    /// Move one plot left.
    Left,
    /// Move one plot right.
    Right,
    /// Plant a seed in the plot here.
    Plant,
}

/// What happened.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Event {
    /// The player moved to this plot.
    Moved(usize),
    /// The seed grew in this plot.
    Grew(usize),
    /// The seed didn't grow, and is gone.
    Wilted(usize),
}

/// Why a command was refused.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Refusal {
    /// There is no plot that way.
    Edge,
    /// Something already grows here.
    Planted,
    /// The game is over.
    Over,
}

/// How the game ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Outcome {
    /// Something grows in every plot.
    Won,
    /// The seeds ran out first.
    Lost,
}

Rust: each enum is one of a fixed list. Event::Moved(usize) is a possibility that carries data: moved, and to which plot. Vec<bool> is a list of true-or-false, one for each plot.

Serialize and Deserialize, derived here, write the code that turns a value into text and back. It's what lets a state be saved and hashed, as you'll see below. Garden's fields have no pub, so nothing outside this file can change them: code elsewhere has to send a command, and the compiler enforces it.

TurnRules

The rules implement shuyi_core::TurnRules for their state, the engine's trait, its list of functions a turn game's rules promise to have:

impl TurnRules for Garden {
    type Command = Command;
    type Event = Event;
    type Error = Refusal;
    type Outcome = Outcome;

    fn check(&self, command: &Command) -> Result<(), Refusal> {
        if self.outcome().is_some() {
            return Err(Refusal::Over);
        }
        match command {
            Command::Left if self.at == 0 => Err(Refusal::Edge),
            Command::Right if self.at + 1 >= self.plots.len() => Err(Refusal::Edge),
            Command::Plant if self.plots[self.at] => Err(Refusal::Planted),
            _ => Ok(()),
        }
    }

    fn apply(&mut self, command: &Command) -> Result<Vec<Event>, Refusal> {
        // All or nothing: a refused command changes nothing.
        self.check(command)?;
        self.turn += 1;
        let event = match command {
            Command::Left => {
                self.at -= 1;
                Event::Moved(self.at)
            }
            Command::Right => {
                self.at += 1;
                Event::Moved(self.at)
            }
            Command::Plant => {
                self.seeds -= 1;
                // Luck comes from the state's RNG, never from outside.
                if self.rng.roll_percent() < self.grow_percent {
                    self.plots[self.at] = true;
                    Event::Grew(self.at)
                } else {
                    Event::Wilted(self.at)
                }
            }
        };
        Ok(vec![event])
    }

    fn legal_commands(&self) -> Vec<Command> {
        [Command::Left, Command::Right, Command::Plant]
            .into_iter()
            .filter(|command| self.check(command).is_ok())
            .collect()
    }

    fn outcome(&self) -> Option<Outcome> {
        if self.plots.iter().all(|&grows| grows) {
            Some(Outcome::Won)
        } else if self.seeds == 0 {
            Some(Outcome::Lost)
        } else {
            None
        }
    }
}

Rust: the type lines fill in the trait's blanks: this game's command, event, error and outcome. Result<(), Refusal> is either "fine, nothing more to say", Ok(()), or a refusal, Err(...).

self.check(command)? is the line that makes apply all or nothing. The ? says: if check refused, hand that refusal back now, before anything has changed. The compiler helps too: check has &self, so it can't change the state, and apply has &mut self, so it may.

Command::Left if self.at == 0 => ... is a match arm with a guard: it only fits if the if is true too. self.plots[self.at] reads item number at from the list, counting from 0.

.into_iter().filter(|command| ...).collect() goes through the three commands, keeps those that pass, and gathers them into a list. .all(|&grows| grows) asks whether every plot grows.

Its contract:

A screen sends the commands (the garden's update, in chapter 2) and shows the state; it never changes the state itself. To see that the rules decide everything, change one of them. Here, planting stops costing a seed:

In src/rules.rs:

- self.seeds -= 1;
+ self.seeds -= 0;
After planting once, the screen says 8 seeds left.
Before
After planting once, the screen says 9 seeds left: planting no longer costs a seed.
After

That's one line of the rules. No screen and no drawing changed, and the whole game follows. Numbers in the game's files work the same way. With a chance of 0 percent, no seed ever grows, and the rules' outcome ends the game:

In files/garden.ron:

- grow_percent: 75,
+ grow_percent: 0,
The player has pressed plant nine times on the first plot. A sprout grows there, and 8 seeds are left, as the later presses were refused.
Before
No seed ever grows. 0 seeds left, the first plot is still bare, and the help line says Out of seeds.
After

A rule's test needs no screen at all:

    #[test]
    fn planting_at_the_edge_and_moving_past_it() {
        let mut garden = Garden::new(&plan(), 7);
        // A refused command changes nothing.
        assert_eq!(garden.apply(&Command::Left), Err(Refusal::Edge));
        assert_eq!(garden, Garden::new(&plan(), 7));
        let events = garden.apply(&Command::Plant).unwrap();
        assert!(matches!(events[..], [Event::Grew(0) | Event::Wilted(0)]));
        assert_eq!(garden.seeds(), 8);
    }

Rust: matches! asks whether a value fits a pattern, and | inside it means "or". events[..] is the whole list, seen as a slice so it can be matched.

History and undo

A History is the state a game started in and every command since. Since the rules are deterministic, that's enough: any earlier point is rebuilt by replaying, which is what undo, rewind and saving a game in progress need. There's no need to keep a copy of the state for each move.

    #[test]
    fn a_history_rewinds_by_replaying_its_commands() {
        let mut history = History::new(Garden::new(&plan(), 7));
        for command in [Command::Plant, Command::Right, Command::Plant] {
            history.push(command);
        }
        let now = history.state_at(history.len());
        assert_eq!(now.at(), 1);
        // Back to the game after its first command: the rest is forgotten.
        let then = history.rewind_to(1).unwrap();
        assert_eq!(then.at(), 0);
        assert_eq!(history.commands(), [Command::Plant]);
    }

A history is written as its first state and its commands, which is also what a record of a play is.

Determinism and the hash

How do you know two runs really played the same? Comparing whole states by eye doesn't scale. A hash is a short number worked out from a whole value, and it changes if any of it does.

shuyi_core::state_hash hashes any state that serializes, the same on every platform and every run. Two runs that should agree compare their hashes step by step, and the first step that differs is where to look:

    #[test]
    fn the_same_seed_and_commands_give_the_same_hash() {
        let play = |seed| {
            let mut garden = Garden::new(&plan(), seed);
            for command in [Command::Plant, Command::Right, Command::Plant] {
                garden.apply(&command).unwrap();
            }
            state_hash(&garden).unwrap()
        };
        assert_eq!(play(7), play(7));
    }

Rust: let play = |seed| { ... }; keeps a small nameless function in a name, so the test can call it twice. .unwrap() opens a Result, and stops the test if it's an error.

A state hashes the same each run only if it serializes the same: its maps must be BTreeMap and its sets BTreeSet, which keep their items in order, never a hash map, whose order changes from run to run.

Time in rules

Rules have no clock, so they count what they have: turns, or in a real-time game, steps. shuyi_core::timer has the two timers a game sets and forgets. A Countdown fires once and is the cooldown too; an Every repeats. They live in the state, so they save, hash and replay with it:

/// What the garden times, in its state: rain every few turns, and a watering
/// can that needs a wait between uses.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Seasons {
    rain: Every,
    can: Countdown,
}

impl Seasons {
    /// Rain every `rain` turns, and a can ready now, then every `can` turns.
    pub fn new(rain: u32, can: u32) -> Self {
        Self {
            rain: Every::new(rain),
            can: Countdown::new_ready(can),
        }
    }

    /// Where a turn ends: whether it rains.
    pub fn end_turn(&mut self) -> bool {
        self.can.advance(1);
        self.rain.advance(1) > 0
    }

    /// Uses the can if it's ready, and says whether it was.
    pub fn water(&mut self) -> bool {
        let ready = self.can.ready();
        if ready {
            self.can.restart();
        }
        ready
    }
}

A turn game advances them by one where its turn ends, in apply. A real-time game advances them by one in each step, stepping its rules once for each of input.ticks(), so a timer fires on the same step at 30 and at 60 frames a second. input.dt() is the seconds since the last frame, for what only looks, such as a glide or a blink: a rule timed by it would change with the frame rate, and replays would stop agreeing.

Rules in fixed steps

A real-time game's rules implement shuyi_core::tick::TickSim instead of TurnRules. There are no commands: each step gets a TickInput, what each player pressed, held and let go of since the last step, and answers with what happened. Here a bee flies over the garden while Left or Right is held, a plot every 10 steps:

/// The game's Left and Right, by their places in its list of actions.
pub const LEFT: ActionId = ActionId(2);
/// See [`LEFT`].
pub const RIGHT: ActionId = ActionId(3);

/// A bee over the plots, in real time: it flies a plot along every 10
/// steps while Left or Right is held.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Bee {
    at: usize,
    plots: usize,
    wings: Every,
}

/// What the bee did in a step.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Buzz {
    /// It landed on this plot.
    Landed(usize),
}

impl Bee {
    /// A bee over the first of `plots` plots.
    pub fn new(plots: usize) -> Self {
        Self {
            at: 0,
            plots,
            wings: Every::new(10),
        }
    }

    /// The plot it is over.
    pub fn at(&self) -> usize {
        self.at
    }
}

impl TickSim for Bee {
    type Event = Buzz;

    fn step(&mut self, input: &TickInput) -> Vec<Buzz> {
        let left = input.is_held(0, LEFT);
        let right = input.is_held(0, RIGHT);
        if left == right {
            // Neither held, or both: it hovers.
            self.wings.reset();
            return Vec::new();
        }
        if self.wings.advance(1) == 0 {
            return Vec::new();
        }
        let last = self.plots.saturating_sub(1);
        self.at = if right {
            (self.at + 1).min(last)
        } else {
            self.at.saturating_sub(1)
        };
        vec![Buzz::Landed(self.at)]
    }

    fn hash(&self) -> u64 {
        state_hash(self).map_or(0, |hash| hash.0)
    }
}

Rust: ActionId(2) is an action by its place in the game's list of actions, counting from 0: the rules name actions that way, so they need nothing but the engine's core. left == right is true when neither is held, and when both are.

The contract is TurnRules' first one: the same state and input always give the same events and the same new state. Events are also the only way out of a step: a sound or a picture comes from what step returns, never from inside it.

A screen doesn't step the rules itself. It keeps a TickDriver (shuyi_ui::tick), and its update calls driver.update(&mut self.bee, input) with the frame's input. The driver steps the rules as many times as the frame has steps due (input.ticks(), 0 to 4) and hands back each step's events with its number. Frames and steps don't line up: on a 120 Hz screen every other frame has no step, and a late frame has several. So the driver keeps what each frame read until a step takes it:

A screen whose rules step calls driver.clear() from its covered, so a press made just before a pause menu opens doesn't fire after it closes.

A record of a real-time run is its seed and each step's input, a TickRecord, with the stamp of the build that made it. TickDriver::recording keeps one as the game plays, and tick::replay plays it again, giving the state's hash after each step:

    #[test]
    fn the_bee_flies_a_plot_every_ten_steps_and_its_record_replays() {
        use shuyi::core::tick::{self, TickRecord};
        use shuyi::core::{ContentHash, Made};
        use shuyi::prelude::UiAction;
        use shuyi::ui::FrameInput;
        use shuyi::ui::tick::{ActionIds, TickDriver};

        // The rules' ids are the game's actions'.
        assert_eq!((UiAction::Left.id(), UiAction::Right.id()), (LEFT, RIGHT));
        let made = Made::new("garden 1.0", ContentHash(0));
        let mut bee = Bee::new(6);
        let mut driver = TickDriver::new().recording(TickRecord::new(made.clone(), 1));
        // Right held for a second, at 30 frames a second: two steps a frame.
        for _ in 0..30 {
            let frame = FrameInput::new(vec![], 1.0 / 30.0, vec![UiAction::Right]);
            driver.update(&mut bee, &frame.with_ticks(2));
        }
        assert_eq!(bee.at(), 5);
        // The record of the 60 steps replays to the same bee.
        let record = driver.take_record().unwrap();
        let mut again = Bee::new(6);
        let hashes = tick::replay(&mut again, &record, &made).unwrap();
        assert_eq!(hashes.len(), 60);
        assert_eq!(again, bee);
    }

Rust: use lines inside a function name things for that function alone. vec![UiAction::Right] is the list of actions held in each of those frames.

The record holds what each step saw, after the driver handed out the presses, so it replays the same whatever frame rate made it. A record from another build is refused, naming what differs: a new version of the rules may step the same inputs differently.

A rules crate of their own

A game made with --template grid keeps its rules in a crate of their own, crates/rules, which may depend on the engine's core and plain data only. cargo shuyi lint checks that fence: no floats, hash maps or clock in the rules' code (SH2103), and no screen, device or tool crate, nor a crate of outside luck, under them (SH2104).

When it goes wrong

The rules ask for something a type doesn't have. Take the Serialize off Garden, and everything that saves or hashes it stops compiling. The message's last lines tell you what to add:

error[E0277]: the trait bound `Garden: serde::Serialize` is not satisfied
   = note: for local types consider adding `#[derive(serde::Serialize)]` to your `Garden` type

A float in the rules. Make grow_percent an f32, and the compiler stops you first, because a type that derives Eq, exact equality, can't hold one, and floats don't have that:

error[E0277]: the trait bound `f32: Eq` is not satisfied

Not every float meets that. In a grid game, cargo shuyi lint finds the rest. In a blank game, with no rules crate for it to fence, it says so under limits, and keeping floats out is up to you.

A replay that disagrees. If two runs with the same seed and commands give different hashes, something from outside the state got in: the clock, a hash map's order, a random number from outside SimRng. The first step that differs names the command to look at (chapter 8).

Nothing happens when you press a key. A refused command changes nothing, and the screen above ignores the refusal with let _ =. So a walk into the edge is silent, as it should be. If you expected a change, ask the rules directly in a test, and read the refusal: assert_eq!(garden.apply(&Command::Left), Err(Refusal::Edge)).

Who moves

The bots of chapter 8 play any rules that also say whose move it is and which turn it is:

/// Who moves, for the bots: a garden has one player, so its side is `()`.
impl Sides for Garden {
    type Side = ();

    fn side_to_move(&self) {}

    fn turn(&self) -> u32 {
        self.turn
    }
}

Rust: a garden has one player, so its side is (), a value holding nothing, and side_to_move has nothing to give back.