The book · Chapter 3

Actions, never keys

The first thing you would write in a game is "if Enter was pressed, plant a seed". It works, and it is a bug waiting to happen. Players rebind every key and button, a controller has no Enter, and two keyboard layouts already use different keys. A key written into a game's code, or into text a player reads, stops working after a rebind, or it lies: the help line says "press Enter", and Enter does nothing.

So the code never asks about keys. A screen reacts to the game's actions, the things a player can ask for (Up, Confirm, Plant). The keymap file says which keys and buttons give them, and text names a key through the player's keymap. Then every player can change their keys, a controller just works, and the help line is always right:

In files/keymap.ron:

- "Confirm": ["Enter", "Space", "z"],
+ "Confirm": ["Space", "Enter", "z"],
The title's help line says Enter plant.
Before
The help line says Space plant. No code changed, and the words follow the keymap.
After

Only one file changed. The help line is text that asks the keymap for the key, so it followed.

Reacting to an action

A screen's update gets a FrameInput of the game's actions: input.actions holds those pressed this frame, and input.is_held(action) says whether one is held down. A held action that repeats comes again at the keymap's repeat rate, so a screen never times key presses itself. When the game comes back from a suspend (the device slept, or the player switched apps), or its window loses focus, nothing is held any more: a key let go while it was away doesn't stay held, and one still down counts once it is pressed again. While the window has no focus the controllers do nothing. The garden's update, in chapter 2, turns each action into a command of its rules.

Each screen declares the actions it reads, in Screen::actions. cargo shuyi check --target all checks that the required ones bind to distinct inputs of each target the game ships on (a target is a kind of machine a game runs on, and "distinct" means no two need the same button), and in tests the Harness hands a screen only the actions it declares. A screen that declares none (None) counts as reading every action, and the check warns about it.

The keymap file

files/keymap.ron gives each action, by its name, up to three key chords in each layout. A chord is a key with any keys held with it, such as Shift+a, or just a, and a layout is one complete set of keys for a player to pick:

    layouts: [
        (
            id: "Arrows",
            name: "layout.arrows",
            keys: {
                // Held, they repeat.
                "Up": ["Up", "w"],
                "Down": ["Down", "s"],
                "Left": ["Left", "a"],
                "Right": ["Right", "d"],
                "Confirm": ["Enter", "Space", "z"],
                "Cancel": ["Backspace", "x"],
            },
        ),
    ],

It also gives each action's controller buttons, named by their place on the pad (South, East, DpadUp), never by a label such as "A", since the same place has a different label on another make of pad. Then come the timings of repeated keys, and how far a stick goes before it counts as pushed:

    // Buttons by their place on an Xbox-shaped pad.
    pad: {
        "Up": ["DpadUp", "LeftStickUp"],
        "Down": ["DpadDown", "LeftStickDown"],
        "Left": ["DpadLeft", "LeftStickLeft"],
        "Right": ["DpadRight", "LeftStickRight"],
        "Confirm": ["South"],
        "Cancel": ["East"],
    },
    // Each target's own buttons, by its profile's id, for a target with
    // no keyboard; desktop and web play with the keys above.
    targets: {},
    repeat: (delay_ms: 300, interval_ms: 70),
    stick: (press_percent: 50, release_percent: 35),

Plain Escape and Delete may not appear: the rebinding screen keeps them, Escape to back out of a capture and Delete to empty a slot. cargo shuyi check reads the file and names a wrong line. On a Nintendo-style pad the engine swaps Confirm and Cancel by itself.

Naming a key in text

In the language file, an action's name in braces shows the player's own key for it, and {Cursor} the four direction keys:

    // The garden.
    "garden.seeds": "{seeds} seeds left",
    "garden.help": "{Cursor} move · {Confirm} plant · {Cancel} leave",
    "garden.won": "Every plot grows. {Confirm} back",
    "garden.lost": "Out of seeds. {Confirm} back",

ctx.text_with("garden.help", &[]) gives "arrows move · Enter plant · Backspace leave" with the default keys, and other words once the player rebinds. Text built in code names a key with key_name, and passes it in as an argument:

/// "Enter plants", or whichever key the player put on Confirm: text built
/// in code names a key through the player's keymap, never by its name.
pub fn plant_hint(ctx: &Ctx<Data>) -> String {
    let key = key_name(ctx.help_keys(), UiAction::Confirm);
    ctx.text_with("hint.plant", &[("key", &key)])
}

Rust: &Ctx<Data> lends the function the context, to look at. &[("key", &key)] lends a list of pairs, each a tuple, values side by side: here the placeholder's name, and what to put there.

So a placeholder's first letter says who fills it. A capital, {Confirm}, names a key: an action of the game, or {Cursor}. Lowercase, {seeds} or {key}, is a value the screen passes. cargo shuyi check fails on a capitalized name that is no action.

Both follow the device the player pressed last: keys on the keyboard, the pad's own button names on a controller. An optional action may have no key; the text then shows the language's keys.not_mapped words.

cargo shuyi lint fails on a key named in the game's code or text, with its file and line (problem code SH2101).

Rebinding

The engine's Key bindings screen (shuyi::ui::screens::key_bindings) lets the player rebind three key slots and three button slots for each action, and saves them in the player's profile. It refuses to close while a required action has no key or no button. A keymap file with several layouts lets a player pick one on the layout picker the first time the game starts.

You don't write any of this. It works since your code only ever asked for actions.

Actions of a game's own

The short path plays with UiAction's six moves, which the engine's menus and screens react to too. A game that needs more (a watering can, a debug key) writes its own action enum, a type that is one of a fixed list, and implements ActionSet for it. A trait like ActionSet is a list of functions that a type promises to have:

/// The actions of a garden game that needs more than the six moves: a
/// watering can, and a key to leave with.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum GardenAction {
    /// Walk left.
    Left,
    /// Walk right.
    Right,
    /// Plant a seed.
    Plant,
    /// Water the plot: optional, so a player may leave it with no key.
    Water,
    /// Leave the garden.
    Leave,
}

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

    const ALL: &'static [Self] = &[
        Self::Left,
        Self::Right,
        Self::Plant,
        Self::Water,
        Self::Leave,
    ];

    /// Its name in the keymap file and the player's saved keys.
    fn name(self) -> &'static str {
        match self {
            Self::Left => "Left",
            Self::Right => "Right",
            Self::Plant => "Plant",
            Self::Water => "Water",
            Self::Leave => "Leave",
        }
    }

    /// Walking repeats while the key is held.
    fn is_repeatable(self) -> bool {
        matches!(self, Self::Left | Self::Right)
    }

    /// Every action but watering must keep a key and a button.
    fn is_required(self) -> bool {
        self != Self::Water
    }

    fn is_rebindable(self) -> bool {
        true
    }

    fn group(self) {}

    /// What the engine's menus and screens take each for.
    fn ui(self) -> Option<UiAction> {
        match self {
            Self::Left => Some(UiAction::Left),
            Self::Right => Some(UiAction::Right),
            Self::Plant => Some(UiAction::Confirm),
            Self::Leave => Some(UiAction::Cancel),
            Self::Water => None,
        }
    }

    /// No key that the player can't move.
    fn fixed_keys() -> &'static [(Chord, Self)] {
        &[]
    }
}

Rust: Self is the type this impl is for, so Self::Left is GardenAction::Left. const ALL: &'static [Self] is a fixed list, kept for as long as the program runs. self with no & takes the value itself, which is cheap here, as Copy in the derive line says a value this small is simply copied.

match handles every possibility. matches!(self, Self::Left | Self::Right) is a short way to ask "is it one of these?". type Group = (); fills in one of the trait's blanks, with (), a value holding nothing. So fn group(self) {} has nothing to give back, and an empty body does it.

The game's data becomes BasicData<GardenAction, Plan> and its game BasicGame<GardenAction, Plan>, and its keymap file names every action of ALL:

    layouts: [
        (
            id: "Arrows",
            name: "layout.arrows",
            keys: {
                "Left": ["Left", "a"],
                "Right": ["Right", "d"],
                "Plant": ["Enter", "Space"],
                "Water": ["w"],
                "Leave": ["Backspace"],
            },
        ),
    ],
    pad: {
        "Left": ["DpadLeft", "LeftStickLeft"],
        "Right": ["DpadRight", "LeftStickRight"],
        "Plant": ["South"],
        "Water": ["West"],
        "Leave": ["East"],
    },

An engine screen declares the game's actions for the UiActions it reacts to with shuyi::ui::input::ui_actions.

Tests

Harness scripts press the game's default keys by name (h.keys("Enter Right")), h.pad("South") presses a button by its place, and h.act(action) presses whatever key the player bound to an action. A test of rebinding rebinds in the test, then presses the new key.

When it goes wrong

cargo shuyi check reads the keymap and the language file, and these are its real answers.

A misspelled action. It says what it knows, and that the real one is now missing:

check: 2 error(s)
  files/keymap.ron:17: error[SH2008]: layout "Arrows": unknown action "Confirn"; known actions: Up, Down, Left, Right, Confirm, Cancel
  files/keymap.ron: layout "Arrows": missing action "Confirm" (list it with [] to leave it unbound)
help[SH2008]: use one of the names the message lists, or fix the spelling

A misspelled key:

  files/keymap.ron:18: error[SH2008]: layout "Arrows": action "Cancel": unknown key chord "Backspce"; expected a key such as "h", "0", ";", "Left", "Enter", "Escape", "Space", "Tab", "Backspace" or "F1", optionally prefixed by "Shift+"

A key the rebinding screen keeps:

  files/keymap.ron:18: layout "Arrows": action "Cancel": "Escape" can't be bound here: Esc and Delete are fixed, see controls.md

An action misspelled in the language file. check lists the actions and {Cursor}, as the names a capitalized placeholder may use:

check: 1 error(s)
  files/en.ron:15: error[SH2008]: "title.help": unknown action "{Confirn}"; known actions: Up, Down, Left, Right, Confirm, Cancel, and {Cursor} for the cursor keys
help[SH2008]: use one of the names the message lists, or fix the spelling

cargo shuyi lint reads your code and your text, and finds a key named in either:

lint: 1 finding(s)
  crates/game/files/en.ron:11: error[SH2101]: text tells the player to press "Enter": "Press Enter" — react to an action (the keymap names the keys), or name a key through the player's keymap
help[SH2101]: react to an action instead, and name a key in text through the player's keymap

One more reaches neither:

A screen reads an action it didn't declare. Take UiAction::Left off the garden's actions, keep reading it in update, and in a test the Harness holds it back, so Left does nothing. A test that walks left then fails, with the plot it stayed on:

assertion `left == right` failed
  left: 2
 right: 1

If a key does nothing in a test, look at the screen's actions first.