Testing & Scenarios

testing scenario validation

Testing & Scenarios

Scenario tests let you pin down how a table should behave and check it automatically — that a shot scores what you expect, that a mode lights when it should, that the stack pops on a drain. A scenario is a small declarative file (.cade.test) that defines a starting state, a sequence of switch events, and expectations; cade runs it against the real scoring engine and tells you whether each expectation held.

You write them in the same engine that runs your table, so a passing scenario is real evidence — not a mock.

Demo

Happy path

The recording shows the demo table, then a scenario that expects a target hit to score 1000 points and light bonus_mode, then cade scenario run confirming both expectations pass:

cade — scenario playground loading…
Loading the cade engine…
results
Press “run scenario” to execute the test against the table.

Catching a bug

The next scenario asserts that a bumper hit should score at least 2000 points — but the table only awards 500 per bumper hit. cade scenario run reports the failed expectation along with the score it actually saw:

cade — scenario playground loading…
Loading the cade engine…
results
Press “run scenario” to execute the test against the table.

Running scenarios from the CLI

On the desktop, the same engine runs from the command line:

# Run one scenario against a table
cade scenario run scenarios/target_lights_bonus.cade.test --scoring-config mytable.cade

# Run every scenario in a directory
cade scenario run scenarios/ --recursive

# Machine-readable output, or stop on the first failure
cade scenario run scenarios/ --format json
cade scenario run scenarios/ --fail-on-error

Scenario files use the .cade.test extension. A run exits non-zero if any expectation fails, so it drops straight into CI.

Anatomy of a .cade.test

scenario "target_lights_bonus" {
  name        = "Target lights bonus"
  description = "A target hit scores and lights bonus_mode"

  # Where the game starts. Warps the engine to this state before any events.
  initial_state {
    state_id     = "fresh"
    description  = "new ball, no score"
    ball_in_play = 1

    player "1" {
      name   = "P1"
      active = true
      score  = 0
    }
  }

  # The events to play, in order. `type` is the engine event name — the same
  # name your scoring rules and `on "..."` handlers match.
  event "hit_target" {
    type = "device.target.hit"
    time = "0ms"
    data = {}
  }

  # What must be true afterward.
  expect "scored" {
    description = "target awards at least 1000"
    type        = "score"
    condition = {
      var.min_score = 1000
    }
  }
}

initial_state

Sets up the game before events run: which ball is in play and each player’s active flag. Expectations read from this state, so define at least the player you assert on.

Scores aren’t injected. initial_state describes the starting state, but the engine begins each run at zero — a non-zero starting score here won’t seed it. Assert on the points your events award (that’s what scenarios are for), not on a pre-set balance.

event

Each event block plays one engine event. type is the event name the engine scores and routes on (for example device.target.hit, device.slingshot.hit). time orders events; data passes optional event fields.

expect

Each expect block is one assertion, with a type and a condition:

typeCommon condition keys
scoremin_score, max_score, total_score, player_score, score_difference
stateball_in_play, active_modes, mode_active, mode_completed, player_count, game_active
eventrequire_success, event_count, min_events, max_events, success_rate

For example, assert a mode is running, or that every event processed cleanly:

expect "bonus_running" {
  type      = "state"
  condition = { var.mode_active = "bonus_mode" }
}

expect "clean" {
  type      = "event"
  condition = { var.require_success = true }
}

Generating events

For soak/performance tests you don’t have to hand-write every event — a generate block synthesizes them. You pick how many, a weighted mix of event types, and how they’re spaced in time:

generate {
  count = 24
  distribution = { "device.spinner.hit" = 1.0 }
  timing   = "decay"   # fast at first, then slowing — a spinner winding down
  duration = "3s"
  seed     = 42        # reproducible
}

The timing modes:

ModeSpacingModels
uniformEvenly spacedA steady stream
randomRandom instantsScattered hits
burstClusters with gapsPop-bumper flurries
decayGaps grow (fast → slow)A spinner struck with velocity, winding down
accelerateGaps shrink (slow → fast)A ramp toward a climax

decay is the spinner spin-down: a single-switch distribution plus timing = "decay" reproduces the real fast-then-slow burst.

Record once, test forever

You don’t have to hand-author every scenario. Cade records each session you play, so you can capture a real game and turn it into a regression test that replays forever.

Sessions are recorded automatically while you run a table. Once you’ve played the sequence you want to lock down, export it and convert it to a .cade.test:

# 1. Play your table — the session is recorded as you go
cade run mytable.cade

# 2. Find and export the session you just played
cade history list                              # show recorded sessions
cade history export --latest -o session.json   # export the most recent

# 3. Convert it into a scenario test
cade replay to-scenario session.json --with-expectations -o regression.cade.test

# 4. Run it forever, in CI like any other scenario
cade scenario run regression.cade.test

--with-expectations auto-generates the expect blocks from the scores the session actually produced, so the captured game becomes its own baseline — re-run it after any rules change and a different outcome fails the test. Narrow what gets captured with --ball <n> (one ball only) or --scoring-only (drop non-scoring events), and hand-edit the generated file like any other scenario afterward.

See the History & replay commands in the CLI reference for the full set of export and conversion flags.

Where this fits

cade scenario run and the demo above use the same engine — what you see in the recording is exactly what the CLI produces locally. Author a .cade.test and drop it into CI to pin down how your table behaves.