Testing & Scenarios
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:
Loading the cade engine…
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:
Loading the cade engine…
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_statedescribes the starting state, but the engine begins each run at zero — a non-zero startingscorehere 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:
type | Common condition keys |
|---|---|
score | min_score, max_score, total_score, player_score, score_difference |
state | ball_in_play, active_modes, mode_active, mode_completed, player_count, game_active |
event | require_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:
| Mode | Spacing | Models |
|---|---|---|
uniform | Evenly spaced | A steady stream |
random | Random instants | Scattered hits |
burst | Clusters with gaps | Pop-bumper flurries |
decay | Gaps grow (fast → slow) | A spinner struck with velocity, winding down |
accelerate | Gaps 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.