The tutorial · Step 5

Undo

Play this step Its whole project

A puzzle needs undo: one wrong push, and the level can't be won. This step adds it, in two parts. The game needs a new action, since "undo" isn't one of the engine's six. And it needs to remember what happened, which is where commands pay off again.

After a push and an undo, the box and the player are back where they started, and the help line names the undo key.
FileWhat changes
crates/game/src/action.rsNew: the game's own actions
crates/game/files/keymap.ronA key and a button for Undo
crates/game/src/board.rsKeeps a history, and takes back moves
lib.rs, title.rs, stages.rs, tests/it/main.rsUiAction becomes Action
shuyi.ronSays action.rs is allowed to name a key
crates/game/files/en.ronThe help line mentions undo
crates/game/tests/board.rsA test of undo

The game's own actions

So far, your game has used UiAction, the engine's six ready-made moves. A game that wants more writes its own list. Make crates/game/src/action.rs:

//! What a player can ask for: `UiAction`'s six moves and Undo, every one
//! rebindable, their default keys and buttons in `files/keymap.ron`.

use serde::{Deserialize, Serialize};
use shuyi::assets::keymap::{ActionSet, Chord, Key, UiAction};

/// Everything a player can ask for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum Action {
    /// Walk up, or move a menu's focus up.
    Up,
    /// Walk down, or move a menu's focus down.
    Down,
    /// Walk left.
    Left,
    /// Walk right.
    Right,
    /// Choose a menu item.
    Confirm,
    /// Go back.
    Cancel,
    /// Take back the last move.
    Undo,
}

/// The keys every keymap has on top of its own: plain `Escape` cancels.
const FIXED_KEYS: [(Chord, Action); 1] = [(Chord::plain(Key::Escape), Action::Cancel)];

impl ActionSet for Action {
    /// No two actions share a key.
    type Group = ();

    const ALL: &'static [Self] = &[
        Self::Up,
        Self::Down,
        Self::Left,
        Self::Right,
        Self::Confirm,
        Self::Cancel,
        Self::Undo,
    ];

    fn name(self) -> &'static str {
        match self {
            Self::Up => "Up",
            Self::Down => "Down",
            Self::Left => "Left",
            Self::Right => "Right",
            Self::Confirm => "Confirm",
            Self::Cancel => "Cancel",
            Self::Undo => "Undo",
        }
    }

    /// The four moves and Undo repeat while held.
    fn is_repeatable(self) -> bool {
        !matches!(self, Self::Confirm | Self::Cancel)
    }

    /// Every action must keep a key: the game can't be played without one.
    fn is_required(self) -> bool {
        true
    }

    fn is_rebindable(self) -> bool {
        true
    }

    fn group(self) {}

    /// What it means to the engine's widgets: Undo means nothing to them.
    fn ui(self) -> Option<UiAction> {
        match self {
            Self::Up => Some(UiAction::Up),
            Self::Down => Some(UiAction::Down),
            Self::Left => Some(UiAction::Left),
            Self::Right => Some(UiAction::Right),
            Self::Confirm => Some(UiAction::Confirm),
            Self::Cancel => Some(UiAction::Cancel),
            Self::Undo => None,
        }
    }

    fn fixed_keys() -> &'static [(Chord, Self)] {
        &FIXED_KEYS
    }
}

This looks long, but it's nearly all a list of facts about seven things. The enum lists the actions, keeping the same six names, so the keymap and the title still read the same. Then ActionSet is the trait the engine uses to learn about them:

Rust: in an impl, Self is the type it's for, so Self::Up is Action::Up. &'static [Self] is a borrowed list of Action that lasts for the whole program.

Shuyi: the one file that may name a key by hand is this one, for that fixed Escape. In shuyi.ron, tell the key check about it, with this line in check_keys: key_files: ["crates/game/src/action.rs"],

Now, swap the type everywhere. Add pub mod action; to lib.rs, with use crate::action::Action; below its other use lines. (crate means "this crate's own code".) Then, replace UiAction with Action in lib.rs, title.rs, stages.rs, board.rs, and tests/it/main.rs. Your editor's find-and-replace is the tool for this. Each of those files also needs use crate::action::Action;, or in the test, use game::action::Action;. The compiler will list each one it can't find.

This is that generic from step 1 paying off: BasicData<Action, stages::Own> is the same form, filled in differently, and the whole engine now works with your actions.

Give Undo its default key in files/keymap.ron, inside the keys of the layout, and its button in the same file's pad, as "Undo": ["West"],:

                "Undo": ["u"],

In the board, every action now counts, so actions gets simpler:

    /// Every action of the game.
    fn actions(&self) -> Option<Vec<Action>> {
        Some(Action::ALL.to_vec())
    }

And your help line, in en.ron:

    // The board.
    "board.help": "{Cursor} walk · {Undo} undo · {Cancel} back",
    "board.help.won": "Solved! {Undo} undo · {Cancel} back",

Here's what never naming a key buys you. Change the key in the keymap, and the words follow:

In crates/game/files/keymap.ron:

- "Undo": ["u"],
+ "Undo": ["r"],
The help line says u undo.
Before
The help line says r undo: the words follow the keymap.
After

History

How would you write undo? You could save a copy of the whole state on every move. In a small puzzle, that would work. There's a neater way.

Shuyi: a History is the state a level started in, and every command since. That's all it holds, because that's all you need: since the rules always give the same result for the same commands, you can get any earlier moment by starting again from the beginning and replaying. This property has a name, determinism, and it's why rules may have no clock, no floats, and no HashSet. It's also how replays, bug reports and online play work.

The board keeps a history beside the puzzle:

/// The board screen.
#[derive(Debug)]
pub struct Board {
    /// The level's name.
    name: String,
    /// The rules' state.
    pub puzzle: Puzzle,
    /// The level's first state and every command since.
    pub history: History<Puzzle>,
}

In Board::new, start it from the first state, with history: History::new(puzzle.clone()), before the puzzle, line. .clone() makes a copy: the history keeps its first state forever, while the puzzle goes on to change. You need use shuyi::core::History; at the top.

Rust: ownership. Each value in Rust has one owner, and handing a value to a function hands over ownership: you can't use it again afterwards. So if History::new was given puzzle itself, the next line couldn't use it. clone makes a second one. Here the compiler's error would tell you "value used after being moved", and that's what it means. You'll meet that error sooner or later, so now you know how to read it.

It records only the commands the rules accepted, so a walk into a wall is no move:

    /// Sends `command` to the rules. A refused one changes nothing.
    fn send(&mut self, command: Command) {
        if self.puzzle.apply(&command).is_ok() {
            self.history.push(command);
        }
    }

Undo rewinds it, replaying every command but the last. The rules are deterministic, so this always gives the same state:

    /// Takes back the last move, if there is one.
    fn undo(&mut self) {
        if let Some(last) = self.history.len().checked_sub(1)
            && let Ok(puzzle) = self.history.rewind_to(last)
        {
            self.puzzle = puzzle;
        }
    }

Rust: checked_sub(1) subtracts, and gives None if the answer would go below zero, as it would here with no moves made. if let ... && let ... needs both to succeed, so the body runs only if there's a move to take back, and the rewind worked. Pressing undo at the start just does nothing.

And the board's update calls it:

    fn update(&mut self, _: &mut Ctx<Data>, input: &FrameInput<Action>) -> Transition<Data> {
        for &action in &input.actions {
            match action {
                Action::Up => self.send(Command::Step(Dir::Up)),
                Action::Down => self.send(Command::Step(Dir::Down)),
                Action::Left => self.send(Command::Step(Dir::Left)),
                Action::Right => self.send(Command::Step(Dir::Right)),
                Action::Undo => self.undo(),
                Action::Cancel => return Transition::Pop,
                Action::Confirm => {}
            }
        }
        Transition::None
    }

Notice that this match lists every action, with no _ arm. So if you add an eighth action later, the compiler will send you here to decide what it does. A catch-all would have quietly ignored it.

To see how the undo depends on that number, take back two moves at a time:

In crates/game/src/board.rs:

- self.history.len().checked_sub(1)
+ self.history.len().checked_sub(2)
After two steps and one undo, the player has gone back one step.
Before
After two steps and one undo, the player has gone back both, to where they started.
After

The test

#[test]
fn undo_takes_back_a_push_and_nothing_before_the_start() {
    let mut h = on_board();
    let start = board(&h).puzzle.clone();
    h.keys("Right");
    assert_ne!(board(&h).puzzle, start);
    h.keys("u");
    assert_eq!(board(&h).puzzle, start);
    h.keys("u");
    assert_eq!(board(&h).puzzle, start);
}

This is a neat test: it keeps a copy of the first state, and after a push and an undo, the whole state must equal it, with assert_eq!. That's only possible since Puzzle derives PartialEq. The second h.keys("u") checks that undoing at the start is harmless.

What you learned

Chapter 3 has more on a game's own actions, and chapter 6 on history. Next: one level isn't a game.