docs play ▸

The engine

crates/typepper-engine is typepper's rules: typing, heat and ranks, combos, levels, fuel, the cop, falling lava words. It has no IO and no UI, so the game (terminal and wasm) and the server run exactly the same code. Its one hard promise is determinism: the same Setup and the same inputs give the same round, on any platform, at any frame rate.

Layout

FileHolds
lib.rsVERSION, Mode, Aim, Setup, Input, Round, Outcome, simulate, Replay::of / setup
test.rsTest: one typing test (heat, ranks, combo, score, levels, fuel, the cop), RANKS, random_words
lava.rsFLOOR IS LAVA's field: drops, lock-on and free aim, landings
rng.rsRng, SplitMix64 written out
replay.rsthe replay format (see replays)
words.txtthe word list

The game keeps everything that draws: ui.rs, fx.rs, lava.rs (the field on screen), menu.rs.

Modes

pub enum Mode {
    Time(u64),                              // BLITZ 15s / 30s / 60s
    Words(usize),                           // SPRINT 10w / 25w / 50w
    Zen,                                    // no clock; esc ends it
    Survival { start_level: u32 },          // fuel that drains faster every level
    Lava { aim: Aim, start_level: u32 },    // FLOOR IS LAVA, lock-on or free
    Pursuit { start_level: u32 },           // HOT PURSUIT: a cop on your caret
}

A mode carries its own options, so "zen from level 7" can't be written. Leveled modes start from 1, 4, 7 or 10 (LEVELS).

MethodGivesUsed for
key()time:30, words:25, zen, survival:4, lava:lock:4, lava:free:1, pursuit:7replays and the server's boards: stable forever
parse(key)the mode, only if the game offers it (one spelling per mode)refusing made-up modes, like pursuit:99, so they can't start boards of their own
name()BLITZ 30s, FLOOR IS LAVA lock-on, HOT PURSUITthe screens, share pages, and local bests (as … from lv4)
start_level(), leveled(), from_level(n)the level, if anythe level picker, scores in words

Never change a key, or old replays and the boards' history stop meaning anything. name() is safe to restyle only alongside a migration of saved bests, since the local bests file is keyed by it.

A round

pub struct Setup { pub mode: Mode, pub seed: u64 }
pub enum Input { Char(char), Backspace, End }      // End: zen's esc

impl Round {
    pub fn new(setup: Setup) -> Round;             // t0 = now
    pub fn input(&mut self, at_ms: u32, input: Input);
    pub fn advance(&mut self, to_ms: u32);
    pub fn result(&self) -> Option<Outcome>;       // once it's over
    pub fn test(&self) -> &Test;                   // what the screens draw
    pub fn ms(&self, now: Instant) -> u32;         // live time as round time
    pub log: Vec<(u32, Input)>,                    // the replay
}
pub fn simulate(setup: Setup, inputs: &[(u32, Input)]) -> Option<Outcome>;

Everything that plays goes through the same two calls:

Whoinputadvance
the live gameon each key, at round.ms(now)every frame, to round.ms(now)
the bot (--demo, --autopilot)at the times it schedulesevery frame
a watched replayas each logged input's time comesevery frame
the server (simulate)every logged input, straight throughuntil the round ends

input(at_ms, …) first runs the clock up to at_ms, then applies the input, and logs it if it did anything. Time never runs backwards: an input earlier than the last one lands at the last one's time. Restarting and leaving aren't inputs: they abandon the round.

Outcome { score, wpm, accuracy, words, ms }: score is what's put up against a best, words in leveled modes, rounded wpm in the rest, none in zen.

simulate plays the inputs, then lets the clock run until the round ends by itself (time up, fuel gone, cooked, busted, last word typed). If it doesn't end within 10 minutes of the last input, which happens to zen without its esc or a sprint left unfinished, there's no outcome.

Determinism

Four things make a round reproducible:

  1. A fixed timestep. The clock ticks 120 times a second, at t0 + n/120 s, whatever the frame rate. Frames only say "run the clock up to now". Inputs land between ticks at their exact millisecond, so a run's sequence of events (ticks and inputs, in time order) depends only on its inputs. Ticks are never recorded.
  2. Whole milliseconds. Inputs are timed in u32 ms from the round's start; inside, t0 + Duration::from_millis(ms) keeps every difference exact.
  3. One seeded rng. Every random pick (the words, the text streamed in mid-run, lava's drops and their positions) comes from the round's Rng: SplitMix64, written out in rng.rs, so no crate update can change the sequence. below(n) is multiply-shift and unit() takes the top 53 bits; both are fully specified.
  4. The same maths everywhere. exp, pow and log come from libm (pure Rust) instead of the platform's math library, which differs between macOS, glibc and wasm in the last bit. Plain float arithmetic is IEEE and identical; Rust doesn't fuse operations behind your back.

The tests that hold it

TestChecks
a_round_plays_back_the_same_at_any_frame_rate7 modes × 3 frame patterns: a scripted player (typos, a slow patch) plays live; replaying the log gives the same result, score, typed text, heat bit for bit, and log
same_numbers_on_every_platformthree rounds recorded on macOS (score, words, end time, heat bits, score points, log length); CI re-checks them on Linux
splitmix64_reference_valuesthe published SplitMix64 sequence
a_replay_plays_its_round_backround → bytes → simulate → the same outcome

The frame-rate test fails if the fixed timestep is removed (checked by hand), so it really guards it.

VERSION

pub const VERSION: u32 = 1;

A replay only plays back right on the rules that recorded it. Bump VERSION when a change would alter any result: balance numbers, the word list, how or when the rng is used, the timestep, anything in Test or Lava that changes what a key does. Don't bump it for anything else (drawing, effects, menus, the server).

The golden test trips on any such change and asserts VERSION == 1 next to its numbers. A rules change therefore goes:

  1. make the change, see same_numbers_on_every_platform fail;
  2. bump VERSION;
  3. re-record the golden numbers (print them from the test, paste them in);
  4. note it in the changelog if players will feel it.

What a bump means elsewhere: the server answers err old-engine to replays from other versions, and the game's outbox drops them; typepper --replay refuses them. Boards keep old runs, scored when they came in. Playing back replays recorded by other versions would need the old engine kept around (see roadmap).