The book · Chapter 4

The frame and skins

What's on the screen and how it looks are two different questions. "The second plot has a sprout in it" is one. "A sprout is a green triangle" is another.

If one piece of code answers both, you can't change the look without risking the game, and a test of the game breaks whenever a color changes. So the engine keeps them apart. Graphics are a skin. A screen says what it shows, as plain data called its view, and a skin says how that looks. Glyph art, pixel art and bought sprite packs then swap without touching the rules, and a test of what happened never breaks because a color changed.

The whole chain is short: the rules' state (everything that's true now) gives the view, and the skin paints the view into the frame.

The garden: a row of six plots, each a small dot, with a box round the first, 9 seeds left under them, and the help line at the bottom.

The view

The garden's view is built from the rules' state and the game's text, and nothing else:

/// What the garden shows: plain data, built from the rules' state and
/// the game's text, which tests read.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GardenView {
    /// Each plot, left to right.
    pub plots: Vec<Plot>,
    /// The plot the player stands at.
    pub at: usize,
    /// The seeds left, in words.
    pub seeds: String,
    /// The help line, or how the game ended.
    pub help: String,
}

/// A plot as the garden shows it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Plot {
    /// Bare soil.
    Soil,
    /// Something grows.
    Sprout,
}

impl GardenScreen {
    /// What the garden shows now.
    pub fn view(&self, ctx: &Ctx<Data>) -> GardenView {
        let garden = &self.garden;
        let plots = garden.plots().iter();
        let help = match garden.outcome() {
            None => "garden.help",
            Some(Outcome::Won) => "garden.won",
            Some(Outcome::Lost) => "garden.lost",
        };
        GardenView {
            plots: plots
                .map(|&grows| if grows { Plot::Sprout } else { Plot::Soil })
                .collect(),
            at: garden.at(),
            seeds: ctx.text_with("garden.seeds", &[("seeds", &garden.seeds())]),
            help: ctx.text_with(help, &[]),
        }
    }
}

It names no glyph, no color and no position: those are the skin's.

Rust: enum Plot is one of a fixed list, Soil or Sprout. usize is a whole number, never negative, of the kind used to count a list's items.

plots.map(|&grows| ...).collect() is an iterator chain: go through each plot, turn each into a Plot, and gather them into a list. |&grows| is a small nameless function whose one argument is grows, and the & takes the value out of the reference it comes as. In Rust, if gives back a value, so if grows { Plot::Sprout } else { Plot::Soil } is one Plot.

match garden.outcome() opens an Option: None while the game goes on, or Some(...) with how it ended. It picks which line of text to show.

The frame

Every screen draws into one frame, the picture the player sees. Its first layer is a console, a grid of cells like an old terminal, each holding one glyph (a character) with its colors. That grid is a GlyphBuffer, sized by the game: 48 × 24 cells of 8 × 16 pixels on the short path, BASIC_CONSOLE, and a game sets its own with BasicGame::console. Over and under the cells go pictures from the game's images (sprites, tiles, a backdrop), each with a layer that says what it covers.

A glyph skin paints cells: fill_rect, draw_box, print (which returns how many cells it used), set, and the prelude's print_centred. Colors come from the palette by name, never as numbers: ctx.palette.engine(EngineColor::Text) for the engine's own roles, and ctx.palette.lookup(name) for one of the game's, from files/palette.ron:

    // The game's own.
    "soil": "#6b4a2b",
    "sprout": "#7ad07a",

A color like #7ad07a is three pairs of digits in base 16, for how much red, green and blue it has, from 00 to ff.

The skin

The garden's skin is one function of its view:

/// The glyph skin: paints a garden's view into the frame's cells, its
/// colors by name from the game's palette.
pub fn paint_garden(view: &GardenView, ctx: &Ctx<Data>, buf: &mut GlyphBuffer) {
    let c = |color| ctx.palette.engine(color);
    let own = |name| ctx.palette.lookup(name).unwrap_or(c(EngineColor::Text));
    let bg = c(EngineColor::Background);
    buf.fill_rect(buf.bounds(), Cell::new(' ', bg, bg));
    let count = i32::try_from(view.plots.len()).unwrap_or(0);
    let left = (i32::from(buf.width()) - count * 4) / 2;
    for (x, plot) in (left..).step_by(4).zip(&view.plots) {
        let (glyph, fg) = match plot {
            Plot::Soil => ('·', own("soil")),
            Plot::Sprout => ('▲', own("sprout")),
        };
        buf.set(x + 1, 10, Cell::new(glyph, fg, bg));
    }
    let at = left + i32::try_from(view.at).unwrap_or(0) * 4;
    buf.draw_box(
        Rect::new(at, 9, 3, 3),
        BoxStyle::Single,
        c(EngineColor::Focus),
        bg,
    );
    print_centred(buf, 14, &view.seeds, c(EngineColor::Text), bg);
    let last = i32::from(buf.height()) - 1;
    print_centred(buf, last, &view.help, c(EngineColor::TextDim), bg);
}

Rust: fg and bg are the cell's foreground and background colors. let (glyph, fg) = match plot { ... } takes a pair apart: each arm gives back a glyph and a color, side by side, as a tuple, and this names both.

(left..).step_by(4) counts up from left in fours, forever, and .zip(&view.plots) pairs each number with a plot, stopping when the plots run out. So x and plot go along together. .. makes a range.

The cell positions are i32, which may go negative, as something can be drawn partly off the screen. A list's length is a usize. Rust won't mix number types without being told, so i32::try_from(view.at) converts, and unwrap_or(0) says what to use if it can't.

There isn't a single fractional number here: cells are counted, and everything lands on the same place on every machine.

A screen's draw builds the view and hands it to the skin. Since the view says nothing of how it looks, changing the look changes one line of the skin:

In src/frame.rs:

- Plot::Sprout => ('▲', own("sprout")),
+ Plot::Sprout => ('♣', own("sprout")),
The first plot has a green triangle in its box.
Before
The first plot has a green club in its box. The seeds line and the plots are as they were.
After

Nothing else in the game knows. And a color doesn't need code at all:

In files/palette.ron:

- "sprout": "#7ad07a",
+ "sprout": "#5a9bff",
The sprout in the first plot is green.
Before
The sprout in the first plot is blue.
After

A second skin of the same view (sprites in place of glyphs, a one-bit look for a handheld) is a second function, picked by a setting, and nothing else in the game changes. The grid kit's map (shuyi::ui::grid) works this way: a scene of the visible tiles, painted by a glyph skin or a sprite skin, with the game's units by a painter of its own. Hello, Grid shows both.

A picture the skin paints

Pictures from the game's image files are made before the game runs. Some pictures can't be: a video's frames, an old console's screen in an emulator, a minimap of what the player has seen, a moving plasma. Their pixels (the dots a picture is made of) are made while the game runs. A skin paints such a picture itself, as a Painted picture, and places it in the frame like any other:

/// The plasma's name among the frame's pictures.
pub const PLASMA: PaintedId = PaintedId::new("plasma");

/// A skin that paints a 64 × 48 plasma from the step count, pixel by
/// pixel, and shows it twice its size with its top-left at `(x, y)`.
pub fn paint_plasma(step: u32, (x, y): (i32, i32), buf: &mut GlyphBuffer) {
    // Up from 0 to 248 and back down over 64 steps of `v`.
    let wave = |v: u32| u8::try_from((v % 64).abs_diff(32) * 31 / 4).unwrap_or(u8::MAX);
    let mut picture = Painted::rgba(64, 48);
    let rows = picture.pixels_mut().as_chunks_mut::<4>().0.chunks_mut(64);
    for (py, row) in (0u32..).zip(rows) {
        for (px, pixel) in (0u32..).zip(row) {
            let red = wave(px + step);
            let green = wave(py + 2 * step);
            let blue = wave(px + py + 3 * step);
            *pixel = [red, green, blue, 255];
        }
    }
    buf.pictures_mut().update(PLASMA, picture);
    let whole = Rect::new(0, 0, 64, 48);
    let place = Rect::new(x, y, 128, 96);
    buf.add_painted(PaintedSprite::new(PLASMA, whole, place, Layer::Over));
}

The frame keeps its pictures from one frame to the next, by name. Each has a revision, a count that goes up whenever its pixels are written, and the window sends a picture to the graphics card again only when its revision moved. update puts the picture in only if it differs from the one already there, so a plasma painted afresh at every frame costs nothing in the frames where it didn't change.

Rust: as_chunks_mut::<4>() cuts the pixels' bytes into groups of four, red, green, blue and how solid the pixel is (255 is fully), and .0 takes the whole groups. .chunks_mut(64) cuts those into rows of 64 pixels, and (0u32..).zip(rows) numbers the rows, so py is a pixel's row; the inner loop numbers each row's pixels, its column px. abs_diff is how far apart two numbers are, never negative.

A picture comes in three kinds: Painted::rgba (four bytes a pixel, as here), Painted::indexed (a byte a pixel and a palette of up to 256 colors, so changing a color changes every pixel of it at once) and Painted::one_bit (black and white). It's drawn without smoothing, at any size, and on a black-and-white display a colored picture is turned to a pattern of black and white dots as it's drawn. The test reads the picture's revision, not its pixels:

    #[test]
    fn the_plasma_moves_with_the_steps() {
        let black = Cell::new(' ', Rgb::new(0, 0, 0), Rgb::new(0, 0, 0));
        let mut buf = GlyphBuffer::new(48, 24, black);
        let mut revision = |step| {
            buf.fill_rect(buf.bounds(), black);
            paint_plasma(step, (16, 16), &mut buf);
            buf.pictures().get(PLASMA).map(Painted::revision)
        };
        let first = revision(0);
        // The same step paints the same picture: nothing to upload.
        assert_eq!(revision(0), first);
        // The next one moves it.
        assert_ne!(revision(1), first);
    }

Why tests read the view

A test of what happened reads the view, or the state behind it:

    #[test]
    fn planting_shows_in_the_view() {
        let mut h = game().harness();
        h.keys("Enter Enter");
        let garden = h.screen::<GardenScreen>().unwrap();
        let view = garden.view(h.ctx());
        assert_eq!(view.at, 0);
        assert_eq!(view.seeds, "8 seeds left");
        // A test of what happened reads the view: a new glyph or color
        // doesn't break it.
        assert_eq!(view.plots.len(), 6);
    }

Rust: h.screen::<GardenScreen>() asks the stack for the screen of that type, and gives back an Option, as it might not be there. .unwrap() opens it, and stops the test if it's None. That's fine in a test, and in a game you'd handle the None.

Only a test of a look reads cells and colors. h.snapshot() is the frame as text, the cells' glyphs and then their colors:

    #[test]
    fn the_garden_looks_like_a_row_of_plots() {
        let mut h = game().harness();
        h.keys("Enter");
        // A test of a look reads the frame, here as text.
        let frame = h.snapshot();
        assert!(frame.contains("│·│  ·   ·   ·   ·   ·"), "{frame}");
        assert!(frame.contains("9 seeds left"), "{frame}");
    }

Rust: assert! fails the test unless its first argument is true, and the rest is what to print if so, with {frame} filled in by the value of frame.

Change bare soil's glyph from ·, and the second test breaks, as it should, since the look is what it's about. The first test doesn't notice. That's the whole reason for the view.

For a picture, cargo shuyi frame-png garden.png --keys "Enter" paints the frame to a PNG with no window. Every picture in this book is made that way. Look at it, and keep the tests on the view. Chapter 8 says how to keep a look in a test.

Things that move

A sprout that pops up, a menu that slides in, a title that bobs: the skin shows a change over time, and the rules never wait for it. The engine draws such a motion from the time alone. A tween is a value going from one end to another over a time, along an ease curve that says how it speeds up and slows down, and its value at a moment is worked out from how long it has run. Nothing steps it along from the frame before, so it looks the same at any frame rate, and a test can ask for any moment:

/// How a new sprout comes up: from 8 pixels below its plot to its place in
/// 300 milliseconds, a little past and back, eased out with a "back".
pub const SPROUT_RISE: Tween<i32> = Tween::new(8, 0, 300).with_ease(Ease::Out(Family::Back));

/// How many pixels below its plot a sprout planted `ms` milliseconds ago is
/// drawn: worked out from the time alone, so nothing has to update it.
pub fn sprout_offset(ms: u32) -> i32 {
    SPROUT_RISE.value_at(ms)
}

Rust: const makes a value the program has from the start, built by const fns such as Tween::new. Tween<i32> is a tween of whole numbers; a tween of a pair, a color or a number of your own type works the same way.

The curves have the names other engines and websites give them, after Robert Penner's equations: Ease::Out(Family::Back) is easeOutBack on easings.net, and Godot's TRANS_BACK with EASE_OUT. Back goes a little past its end and comes back; Linear moves as the time does, InOut(Sine) starts and stops gently, Out(Bounce) bounces. The API docs of shuyi::core::ease list them all, beside other engines' names. In a RON file a tween is data, like everything else: (from: 8, to: 0, duration: 300, ease: Out(Back)).

The time is a whole number in the unit you choose. A screen counts milliseconds: it keeps the moment its sprout was planted, and draws it at sprout_offset(now - planted). The rules count steps or turns. The curves are worked out in whole numbers, never fractions, so they are the same on every machine, and the rules may use them too.

A test reads the motion at any moment, and asks when it ended:

    #[test]
    fn a_sprout_rises_past_its_place_and_settles() {
        assert_eq!(sprout_offset(0), 8);
        // Over half the way up in a sixth of the time, then a pixel past.
        assert_eq!(sprout_offset(50), 3);
        assert_eq!(sprout_offset(200), -1);
        assert_eq!(sprout_offset(300), 0);
        assert_eq!(sprout_offset(5000), 0);
        // "It landed": true in exactly one frame, given the last frame's
        // time and this one's.
        let frames = [0, 16, 33, 150, 290, 307, 323];
        let landed = frames
            .windows(2)
            .filter(|w| SPROUT_RISE.finished_between(w[0], w[1]))
            .count();
        assert_eq!(landed, 1);
    }

Rust: frames.windows(2) goes through each pair of neighbors in the list, here one frame's time and the next. .filter(...) keeps the pairs where the sprout landed, and .count() counts them.

finished_between is how a screen hears "it landed": given the time of the frame before and this one's, it is true once, so the screen plays its sound or moves on without keeping a flag.

Seeing what the game sees

A debug build can draw what your logic works with over the running game: where the map's cells are, which part of it is in view, where a box collides. This is the debug view. Each thing it can show is a channel, turned on and off from the debug menu's Debug view, which lists every channel the screens running offer; the choice is kept for the next launch. A build for players draws none of it and asks no screen for it.

A screen offers a channel in debug_draw, a method of Screen that does nothing unless you write it. It hands in gizmos: rectangles, lines, crosses, circles and labels, in console pixels, each with a role that says what it shows (bounds, solid, trigger, path, bone, note). The engine picks their colors from the role, so a gizmo never names a color, and debug_draw only reads the screen (&self), so looking never changes the game. The garden's own channel boxes each plot:

/// The garden's channel of the debug view.
pub const PLOTS: Channel = Channel::new("plots", "Garden plots");

/// Hands in a box round each plot's cell, and a cross on the one the
/// player stands at, in console pixels: where the skin put them. The
/// garden screen's `debug_draw` calls it with its view.
pub fn plot_gizmos(view: &GardenView, ctx: &Ctx<Data>, out: &mut Gizmos) {
    if !out.channel_on(PLOTS) {
        return;
    }
    let (cell_w, cell_h) = (i32::from(ctx.console.cell_w), i32::from(ctx.console.cell_h));
    let count = i32::try_from(view.plots.len()).unwrap_or(0);
    let left = (i32::from(ctx.console.cols) - count * 4) / 2;
    for (i, x) in (0..view.plots.len()).zip((left + 1..).step_by(4)) {
        let rect = Rect::new(x * cell_w, 10 * cell_h, cell_w, cell_h);
        out.push(PLOTS, GizmoRole::Bounds, Gizmo::Rect { rect });
        if i == view.at {
            let at = (rect.x + cell_w / 2, rect.y + cell_h / 2);
            out.push(PLOTS, GizmoRole::Note, Gizmo::Cross { at, size: 3 });
        }
    }
}

The grid kit offers its own channel, grid: a map screen calls shuyi::ui::grid::debug::layout_gizmos from its debug_draw, and the cell lines, the map's edges, the cursor's cell and each unit's appear. Hello, Grid's stage and the stage of a shuyi new --template grid game offer it already. A test reads the gizmos as it reads a view, never their pixels:

    #[test]
    fn the_plots_channel_boxes_each_plot_and_marks_the_players() {
        let mut h = game().harness();
        h.keys("Enter Right");
        let garden = h.screen::<GardenScreen>().unwrap();
        let view = garden.view(h.ctx());
        // Off: nothing is built, but the channel is offered.
        let mut off = Gizmos::new();
        plot_gizmos(&view, h.ctx(), &mut off);
        assert_eq!(off.len(), 0);
        assert_eq!(off.offered(), [PLOTS]);
        // On: six boxes, and a cross after the second.
        let mut on = Gizmos::with_on(["plots"]);
        plot_gizmos(&view, h.ctx(), &mut on);
        let roles: Vec<GizmoRole> = on.drawn().iter().map(|d| d.role).collect();
        assert_eq!(roles.len(), 7);
        assert_eq!(roles[2], GizmoRole::Note);
    }

To see the same as a picture, cargo shuyi frame-png out.png --debug plots turns the channels on from the first frame (--debug all turns on every one).

When it goes wrong

A glyph the font doesn't have. The font is a picture of the characters it knows, in files/font/. A character outside it can't be drawn as itself, and nothing stops the game: the window only notes it once in its log. So look at a new glyph in a picture.

A color name that isn't there. ctx.palette.lookup(name) gives an Option, as a name may be missing, and the skin above falls back to the engine's text color. A typo in a name shows as a sprout in the wrong color, not as an error. A missing color of the engine's, or a bad one, is caught by cargo shuyi check:

check: 1 error(s)
  files/palette.ron: missing required colour "focus"
check: 1 error(s)
  files/palette.ron:20: colour "sprout": "green" must start with '#' (format "#RRGGBB")

Trying to change the game in draw. The compiler stops you, as draw only has &self (chapter 2).

On a target's screen

A target is a kind of machine or place a game runs. Each target says how big its display is and how small its text may be, and cargo shuyi check --target checks the game's frame and scale against it (chapter 9). A one-bit display, such as the Playdate's, has only black and white, and takes each palette role's pattern from the game's one-bit map.