The book · Chapter 9

Targets and export

Sooner or later you want someone else to play your game. Your friend has a Mac, your cousin has a Steam Deck with no keyboard, and a stranger only wants to click a link. Each of those machines can do different things, and the worst time to find that out is after you've built for it.

Each platform a game can ship on is a target, and each target has a profile: plain data that says what its hardware can do. Its display (the sizes it shows, its colors, how small text may be), its frame rate, its inputs (which buttons, a pointer, a keyboard), its budgets of time and memory, and the platform backend that runs a game there, the part of the engine that talks to that machine's window, files and sound. A game that needs more than a target has doesn't export there, and the tools say why before anyone builds for it.

The profiles are the engine's targets/ folder: desktop (Windows, Linux and macOS), web and steam-deck on the macroquad backend, and playdate for its own backend, with phones and the Switch planned.

Exporting means making the package a player downloads. The order to work in is: list your targets, check against them, and export.

The targets a game ships on

A game lists its targets in its project file, shuyi.ron, which is written in RON and read by the tools. Each has the size of its frame in pixels, how it scales (integer, whole multiples so pixels stay crisp, or fit, as big as the window allows) and the height of its smallest text:

    // `cargo shuyi check --target all`: the targets the game ships on,
    // each with its frame in pixels, its scale (`integer` or `fit`) and
    // its smallest text's height.
    targets: {
        "desktop": (frame: (384, 384), scale: integer, text_px: 16),
        "web": (frame: (384, 384), scale: integer, text_px: 16),
    },

Where the game runs

A running game knows its target: ctx.platform (shuyi::ui::PlatformFacts) holds the target's id and what its profile says a game may do there. A screen asks it, never a cfg!, Rust's way of asking while compiling, which would make the game a different program on each machine, with a screen you couldn't test:

The facts are the screens'. A game's rules never read them, so the same commands play the same game on every target. A test runs a screen as a target with Harness::as_target, before the screen opens:

    #[test]
    fn on_the_web_cancel_on_the_title_doesnt_quit() {
        let profiles = shuyi_cli::contract::target::engine_profiles().unwrap();
        let mut h = game().harness().as_target(&profiles["web"].profile);
        h.keys("Backspace");
        assert!(!h.quit_requested());
        let mut h = game().harness().as_target(&profiles["desktop"].profile);
        h.keys("Backspace");
        assert!(h.quit_requested());
    }

Rust: &profiles["web"].profile looks up the web entry by its name, and lends its profile. .unwrap() opens a Result, and stops the test on an error. assert!(!x) fails unless x is false, as ! means "not".

Who the game is

Every target gets the same name, id and version, written once in shuyi.ron's app:

    // Who the game is, the same on every target: `cargo shuyi check
    // --target all` checks it, and `cargo shuyi app-info` writes it into
    // `code`, the Rust the game's binary reads.
    app: (
        name: "Moon Garden",
        // Reverse-DNS, unique to the game: its saves are kept under it.
        id: "com.example.moon-garden",
        // Without it, the crate's version.
        version: "0.1.0",
        code: "crates/game/src/app_info.rs",
        // One square PNG, 512 pixels a side or more.
        icon: "art/icon.png",
        // Where `app-info` writes the window's icons, which `code` reads.
        window_icons: "crates/game/icon",
    ),

Checking them

cargo shuyi check --target all

checks, for each target the game lists, that:

Each problem names its target, the setting in shuyi.ron and the numbers. --target <id> checks one.

The web build

The web is the easiest way to hand someone your game: a link, and nothing to install. cargo shuyi web builds the game's binary for the browser (wasm32-unknown-unknown, the name of that target to Rust, which cargo shuyi doctor checks rustup has) and writes it with the page it runs in to a folder. A game that has a web build says so in shuyi.ron:

    // `cargo shuyi web`: the game's binary built for the browser, and the
    // page it runs in, written to `dist/web/`.
    web: (
        package: "moon-garden",
        bin: "moon-garden",
        title: "Moon Garden",
        dist: "dist/web",
    ),

and its binary links with --allow-undefined on the web, so the page's own scripts (the player's storage, controllers) are found when it loads. A link argument reaches a binary only from its own build script, a small Rust program Cargo runs before compiling, crates/game/build.rs:

fn main() {
    if std::env::var("CARGO_CFG_TARGET_ARCH").as_deref() == Ok("wasm32") {
        println!("cargo:rustc-link-arg=--allow-undefined");
    }
}

Rust: std::env::var(...) reads an environment variable, which Cargo sets here to the machine being built for. It gives back a Result, and .as_deref() lets it be compared with Ok("wasm32") as plain text. println! prints a line, and a build script's lines starting cargo: are instructions to Cargo.

Then:

cargo shuyi web --release

writes dist/web/: index.html, the game's .wasm (the compiled game, in the form a browser runs), the engine's scripts, and NOTICES.txt, the licenses of every crate and file the build carries, with the game's own texts that shuyi.ron's notices names. --release builds it optimized, as you would for players. Serve that folder over HTTP and open it in a browser; a page opened from the disk can't load its .wasm. The engine's examples on this site are such folders.

The folder belongs to the build. Each build replaces everything in it, so a file from an older build can't ship, and keep nothing of your own there: web.dist that holds your project or a repository is refused. A server already running in the folder keeps serving the new build.

Export

cargo shuyi export windows --release

makes the package players download. It runs the target's check first (windows, linux and macos all check desktop) and builds nothing if the check finds an error. Then it builds the game for the target and writes into dist/export/<target>/:

Each desktop package builds on its own system: Windows on Windows, Linux on Linux, macOS on a Mac (CI has all three). Every package carries NOTICES.txt. The Windows and macOS ones also carry HOW-TO-OPEN.txt: they aren't signed by a developer, so a player opens them once by hand.

With --release, the build carries nothing of the machine it was made on: its paths are renamed, and a program that still holds one is refused. On Windows and Linux, <package>.debug beside the package keeps the program's line tables for crash reports, and is never shipped. export-report.json beside it gives the version, the sizes, the check's warnings and the signing, and --run runs the package.

A game whose build needs a feature, or whose soundtrack ships as loose files, says so in the export part of shuyi.ron:

export: (
    features: ["licensed-art"],
    beside: [(path: "music"), (path: "voice", optional: true)],
),

Each desktop and web build turns the features on, and each package holds those files and folders under their own names (name gives another). In a .app, a folder goes in Contents/Resources, where the engine's music and voices look for it, and a file goes beside the .app. A path that isn't there refuses the export, unless it is optional. --without <feature> leaves a feature off one build.

What a package gives away

A menu that hides a level doesn't hide its file: a player can open a package and read what is in it. So every export reads its final package back, after every copy its target makes, and writes down what is there in exposure-report.json beside it: each file with its kind, its source, its size and its SHA-256 (a kind of hash, a fingerprint of the file), each file of each pack, whether the file is a pack or a program embeds one, the debug file, the features and what it was built from. The same input gives the same report.

To hold a package to a list, add a disclosure part to shuyi.ron:

disclosure: (
    // Files of the game beside what the exporter writes.
    allowed_files: ["music/title.ogg"],
    // Files a pack may hold.
    allowed_pack_entries: ["maps/first.ron", "palette.ron"],
    // Paths nothing may hold, as a file, in a pack or as a program's text.
    forbidden_paths: ["maps/final-boss.ron"],
    // Texts nothing may hold: put one in each file of what isn't out yet.
    canaries: ["ACT-THREE-CANARY"],
),

A path matches whole, with no pattern, and the part is the same for every target: list each target's names together. What the exporter writes itself (the program, NOTICES.txt, the web page) needs no line. A file or a pack's file the part doesn't name, a forbidden path, a canary, or a file beside the package that this export didn't make refuses the export with SH3012. It names each file and where it came from, and leaves the report and no package, and for the web an empty web.dist. The report and the error name a canary by its place in the list and never by its text, so they can be shared.

This finds what is written plainly: in a file, in what a pack unpacks to, or as UTF-8 or UTF-16 in a program. It can't find what your code compresses itself, or say that a rule isn't in the machine code. What a game must not give away, leave out of the build.

Size and speed

A target's budgets are the most frame time, heap (the memory a program asks for as it runs) and start-up it allows. cargo shuyi run --perf measures each frame headless, and the engine's perf traces say where a frame's time and memory go; the shuyi-perf skill (chapter 10) walks through them.

A frame's time changes from one run to the next, so it can't be a test. cargo shuyi bench measures what doesn't: it plays your benchmark scenarios, files named <name>.bench.ron with the start, the steps and the frames to measure, and counts each frame's instructions (with Valgrind, on Linux), the heap's peak and the package's size. It compares each with the budgets of every target your shuyi.ron lists, and fails naming the scenario, the frame and the target when one is over:

(
    format_version: 1,
    about: "the stage's map, idle",
    start: [(flag: "--stage")],
    frames: 60,
)

Write one for each of your game's heaviest moments: its biggest battle, its fullest menu, its longest dialogue.

When a player's game crashes

If the game panics, the engine writes a crash report beside its saves: crash-<when>.ron in its data folder on a computer, and in the browser's storage on the web. It holds the panic's message and place, the versions, the frame, the screen on top and the log's last lines, never the player's settings or saves, and it stays on the player's machine. The player sends it to you; the game's version in it is shuyi.ron's app.version.

At the next start the game's load hears of it once, in Setup::last_crash: whether the player sees anything, and in what words, is up to the game. A panic the game survived, like a sound library's on a machine with no sound card, keeps its report but isn't offered as a crash. A game that gives Start::crash_record a function writing its record of play (the record its replay reads) gets that in the report too, on a computer, so a crash can be replayed to the panic; a game on the short path gives it to BasicGame::crash_record. A game made with shuyi new --template grid already does: its report holds the open stage's record, the command its rules were applying included. To try it, run the game with SHUYI_PANIC_AT_FRAME=3: it panics at its third frame on purpose and exits with code 101.

When it goes wrong

The point of check --target is that these show up before you build anything. These are its real answers.

A frame too big for the display. A game of 384 × 384 pixels, listed for the Playdate, whose display is 400 × 240. The answer names the target, the setting and the numbers:

check --target playdate: 2 error(s), 1 warning(s) (targets/playdate.ron)
  error: playdate: targets."playdate".frame: frame 384 × 384 at scale 1 is larger than the display 400 × 240
  error: playdate: keymap targets."playdate": the keymap file has no buttons for playdate, which has no keyboard: give it a table in `targets` ("playdate")

The second error there is the other common one: a target with no keyboard needs its own table of buttons in the keymap file.

A target the game doesn't list.

check: error[SH3003]: shuyi.ron's `targets` has no "gameboy" (it lists desktop, web): a game lists the targets it ships on
  help: name a target the message lists, or fix the target profiles' files
  see: shuyi explain SH3003

The placeholder id. It's a warning, so nothing stops you, but fix it before you hand out a build:

  warning: desktop: app.id: "com.example.box-pusher" is a placeholder: two games with this id share their saves on one computer and on one web origin; set your own before you hand out a build, and before the first release, since the save folder follows it

Passing isn't everything. A check also says what it didn't look at, under limits. Read those lines: a display that takes any size, for instance, says its frame fits whatever you gave it.

limits:
  - desktop's display takes any size: its frame fits, and its text is checked at scale 1

A web page that stays blank. If you opened index.html by double-clicking it, that's the rule above: the page must be served over HTTP. If cargo shuyi web itself fails to build, run cargo shuyi doctor, which checks that rustup has the wasm32-unknown-unknown target.