Introduction
Welcome to the Cade Runtime API reference. This documentation covers the local HTTP surface the runtime exposes for health monitoring and debugging, along with the complete HCL configuration spec used to define tables, devices, scoring, audio, and more.
Base URL
http://localhost:8080
The web server is optional and disabled by default. Enable it with --web-server (short form -w), or with web { enabled = true } in cade.conf, and it binds to localhost:8080. Override the host and port with --web-host / --web-port, or the host and port attributes of the web block.
Authentication
Local connections do not require authentication. The web block exposes its endpoints directly without an auth scheme. For production deployments, bind the server to 127.0.0.1 and leave the debug endpoints disabled:
web {
enabled = true
host = "127.0.0.1"
port = 8080
health {
enabled = true
}
debug {
enabled = false
}
logging {
enabled = false
}
}| Attribute | Default | Description |
|---|---|---|
enabled | false | Enable the web server |
host | "localhost" | Address to bind to |
port | 8080 | Port to listen on |
health { enabled } | true | Serve the health endpoint |
debug { enabled } | false | Serve the debug endpoints |
logging { enabled } | false | Serve the log stream |
livetree { enabled } | false | Serve the live event-tree view |
Each sub-block also has a matching flag — --health-enabled, --debug-enabled, --web-logging, --livetree-enabled — and every setting can be overridden by an environment variable using the CADE_ prefix with dots replaced by underscores (CADE_WEB_ENABLED, CADE_WEB_PORT, CADE_WEB_HEALTH_ENABLED).
Configuration Example
Cade uses HCL-based declarative configuration. Tables are defined in .cade files using composable blocks. Here is a pop bumper with a scoring rule that awards points every time it is hit:
variable "int" "bumper_value" {
initial = 1000
min = 100
max = 10000
scope = "player"
}
device "switch" "bumper" "pop_bumper" {
id = 10
settings {
pulse_ms = 20
pulse_power = 60
recycle_ms = 100
}
}
score "event" "bumper_hit" {
when = device.pop_bumper.hit
points = var.bumper_value
}The when attribute names the event that fires the rule, written bare — device.<name>.hit, where <name> is the device’s third label. Events come from switch-category devices; coil devices are driven by the runtime and do not emit events of their own.
Cade Configuration
The Cade CLI is configured through a layered system that supports HCL files, YAML files, environment variables, and command-line flags. Only the settings you need to change must be specified; all others use sensible defaults.
Configuration Files
Cade looks for configuration in the following locations:
~/.cade.hclor./.cade.hcl(HCL format)~/.cade.yamlor./.cade.yaml(YAML format)
If both HCL and YAML files exist, the HCL file takes precedence.
Configuration Precedence
Settings are resolved from highest to lowest priority:
| Priority | Source | Example |
|---|---|---|
| 1 | Command-line flags | --web-port 9090 |
| 2 | Environment variables | CADE_WEB_PORT=9090 |
| 3 | HCL configuration file | .cade.hcl |
| 4 | YAML configuration file | .cade.yaml |
| 5 | Default values | Built-in defaults |
Environment Variables
Environment variables use the CADE_ prefix with underscores representing nested keys:
| Variable | Maps To |
|---|---|
CADE_WEB_ENABLED | web.enabled |
CADE_WEB_PORT | web.port |
CADE_WEB_HOST | web.host |
CADE_WEB_HEALTH_ENABLED | web.health.enabled |
CADE_WEB_DEBUG_ENABLED | web.debug.enabled |
CADE_WEB_LOGGING_ENABLED | web.logging.enabled |
CADE_LOGGING_LEVEL | logging.level |
CADE_LOGGING_FORMAT | logging.format |
CADE_ANALYTICS_ENABLED | analytics.enabled |
CADE_ANALYTICS_BACKEND_TYPE | analytics.backend_type |
Top-Level Blocks
web
Controls the built-in web server for health monitoring and debugging. For what the endpoints serve and how to monitor a running table, see Web Dashboard & Observability.
web {
enabled = false # Enable the web server (default: false)
host = "localhost" # Bind address (default: "localhost")
port = 8080 # Listen port (default: 8080)
health {
enabled = true # Health monitoring endpoints (default: true)
}
debug {
enabled = false # Debug/visualization endpoints (default: false)
}
logging {
enabled = false # Web server access logging (default: false)
}
}logging
Controls application-level logging.
logging {
level = "info" # Log level: debug, info, warn, error (default: "info")
format = "text" # Log format: text, json (default: "text")
}game
Configures game session parameters.
game {
players = ["player1"] # Player IDs, 1-6 unique, non-empty (default: ["player1"])
max_players = 4 # Max players allowed per game, 1-6 (default: 4)
balls_per_game = 3 # Balls each player gets per game (default: 3)
credits_per_player = 1 # Credits deducted when a player joins (default: 1)
}credits
Configures the credit and coin-op system.
credits {
coins_per_credit = 1 # Coins needed for one credit (default: 1)
max_credits = 99 # Maximum credit balance (default: 99)
free_play = false # Skip credit deductions on game start (default: false)
}audio
Controls the audio subsystem.
audio {
disabled = false # Disable audio entirely (default: false)
}analytics
Configures the analytics and telemetry system.
analytics {
enabled = false # Enable analytics (default: false)
chunk_size_limit = 10485760 # Chunk size in bytes (default: 10MB)
chunk_ttl = "24h" # Chunk time-to-live (default: "24h")
max_disk_usage = 1073741824 # Max disk usage in bytes (default: 1GB)
chunk_storage_path = "/tmp/cade-analytics" # Storage path
realtime_patterns = [ # Events sent immediately
"ACHIEVEMENT_*",
"MODE_*",
"MULTIBALL_*"
]
backend_type = "noop" # noop, memory, logging, http, grpc (default: "noop")
backend_url = "" # Endpoint for http/grpc backends
backend_timeout = "30s" # Request timeout (default: "30s")
realtime_enabled = true # Real-time event dispatch (default: true)
chunking_enabled = true # Chunk-based storage (default: true)
}platform
Declares platform driver instances. See the Platform page for driver-specific settings.
platform "grpc" "vpx_bridge" {
port = 50051
enable_gateway = true
enable_reflection = true
}Minimal Example
A working configuration only needs the settings you want to change. Everything else uses defaults:
web {
enabled = true
}This enables the web server on localhost:8080 with health endpoints active and debug endpoints disabled.
Full Example
web {
enabled = false
host = "localhost"
port = 8080
health {
enabled = true
}
debug {
enabled = false
}
logging {
enabled = false
}
}
logging {
level = "info"
format = "text"
}
game {
players = ["player1"]
max_players = 4
balls_per_game = 3
credits_per_player = 1
}
credits {
coins_per_credit = 1
max_credits = 99
free_play = false
}
audio {
disabled = false
}
analytics {
enabled = false
chunk_size_limit = 10485760
chunk_ttl = "24h"
max_disk_usage = 1073741824
chunk_storage_path = "/tmp/cade-analytics"
realtime_patterns = [
"ACHIEVEMENT_*",
"MODE_*",
"MULTIBALL_*"
]
backend_type = "noop"
backend_url = ""
backend_timeout = "30s"
realtime_enabled = true
chunking_enabled = true
}Table Configuration
Table configuration files (.cade files) define the game-specific behavior of a pinball machine. They use HCL syntax and contain blocks for devices, events, variables, scoring rules, signals, fragments, platform bindings, assemblies, and modules.
Top-Level Blocks
A table configuration is composed of these block types:
| Block | Purpose |
|---|---|
device | Physical hardware declarations (flippers, coils, switches, lights) |
global_events | Declares table-wide event names, each with an optional priority |
signal | Detects a pattern of device activity (shot, combo, bank) |
flow | Orchestrates relationships between events over time |
variable | Game state variables with types, scopes, and constraints |
score | Scoring rules, modifiers, and accumulators |
event_handler | General-purpose reactions to events |
fragment | Reusable configuration snippets (static and dynamic) |
pragma | Validation, optimization, and feature control directives |
platform | Platform driver configuration (FAST, gRPC, Virtual) |
synth | Oscillator-based synthesis patches (real-time, no files) |
assembly / use | Reusable parameterized block templates and their instances (guide) |
module | Bundled game logic units (mode, variables, scoring, audio) |
Every device is declared with three labels — device "<category>" "<type>" "<name>" — for example device "coil" "bumper" "bumper_1". Variables, scores, signals, and flows take two labels; assemblies, modules, and event handlers take one.
Expression Syntax
The rule of thumb across .cade files is write everything bare; quote only a literal string. Bare names and dotted references are resolved to their underlying value or event name, so quoting them is unnecessary — a quoted form still parses (for back-compat), but bare is the canonical style used throughout this reference.
Expression-valued fields (points, condition, variable references, and update/modify values) are written bare, using the var. prefix for variables. ${...} interpolation is a parse error:
points = var.bumper_value * var.combo_multiplier / 100
condition = var.balls_in_play == 0Event-matching fields (when, signal, event) and reference lists (switches, signals, devices) name an event or device, also written bare:
when = device.bumper_1.hit
switches = [orbit_entry, orbit_exit]Example
This table wires a pop bumper and an orbit shot, awards points, grows a combo multiplier, and flashes lights on a hit:
name = "Ramp Runner"
version = "1.0.0"
# Game session
game {
max_players = 4
balls_per_game = 3
}
# Typed game-state variables with bounds and scope
variable "int" "bumper_value" {
initial = 1000
min = 100
max = 10000
scope = "global"
}
variable "int" "combo_multiplier" {
initial = 100 # 100% = 1.0x
max = 500 # 500% = 5.0x
scope = "ball"
decay_rate = 10
decay_to = 100
}
# Hardware devices: category / type / name
device "coil" "bumper" "bumper_1" {
id = 10
hardware {
coil = "C10"
switch = "S10"
}
settings {
strength = 60
trigger_time = "50ms"
}
}
device "switch" "lane" "left_orbit_entry" {
id = 70
tags = ["orbit", "playfield"]
}
device "switch" "lane" "left_orbit_exit" {
id = 71
tags = ["orbit", "playfield"]
}
# Indicator lights for the bumper
device "light" "led" "b1l1" { id = 128 }
# Table-wide event names
global_events {
bumper_1_hit {
description = "Bumper 1 hit"
}
left_orbit_complete {
description = "Left orbit shot completed"
}
}
# A signal detects a two-switch shot and awards points on completion
signal "shot" "left_orbit" {
switches = [
left_orbit_entry,
left_orbit_exit,
]
time_window = "3s"
points = 2000
each_switch_points = 50
}
# Scoring rule: react to an event and update a variable
score "event" "bumper_hit" {
when = device.bumper_1.hit
points = var.bumper_value * var.combo_multiplier / 100
update {
combo_multiplier = var.combo_multiplier + 10
}
}
# General-purpose reaction: pulse the coil and flash the light on a hit
event_handler "bumper_1_flash" {
when = device.bumper_1.hit
actions {
pulse_coil "bumper_1" {}
flash_light "b1l1" {
duration = "200ms"
}
}
}Reusing Configuration
Two blocks help avoid copy-pasting repeated hardware and logic.
An assembly is a parameterized template stamped out per flipper, target, or lane. Body blocks use param.<name> for the values supplied by each use:
assembly "flipper" {
parameter "int" "button_id" { required = true }
parameter "int" "coil_id" { required = true }
device "switch" "standard" "button" { id = param.button_id }
device "coil" "standard" "power" { id = param.coil_id }
variable "int" "flip_count" {
initial = 0
scope = "ball"
}
}
use "flipper" "left_flipper" {
button_id = 17
coil_id = 1
}
use "flipper" "right_flipper" {
button_id = 18
coil_id = 2
}A module bundles a game feature — a mode plus its scoring, variables, and handlers. The module name is the mode name; a module with no mode block is always-on infrastructure:
module "multiball" {
description = "Multi-ball play"
mode {
priority = 500
}
variable "int" "balls_in_play" {
initial = 0
scope = "global"
}
stacking {
allow_multiple = false
}
}See the Assembly and Module pages for the full attribute reference.
Pragma
The pragma block provides fine-grained control over validation, optimization, and runtime behavior in Cade table configurations. Pragmas enable gradual migration between validation modes, performance tuning, and feature gating.
Syntax
A table has a single pragma block that defines global settings and optional component-specific overrides:
pragma {
mode = "strict"
optimization_level = 2
experimental = false
deprecated_ok = false
errors_as_warnings = ["undefined_variable", "type_mismatch"]
disable_checks = ["expression_complexity"]
variables {
cache_expressions = true
preallocate = true
type_checking = "strict"
max_dependency_depth = 10
}
scoring {
mode = "normal"
optimization_level = 3
parallel_evaluation = true
cache_size_mb = 20
}
events {
mode = "normal"
parallel_processing = true
max_workers = 4
queue_size = 1000
timeout_ms = 5000
dedupe_window = "250ms"
}
devices {
validation_level = "strict"
debounce_defaults = true
}
}The pragma block is read from your .cade table files — not from cade.conf. Cade scans the table directory and the first pragma block it finds applies to the whole table, so keep a single pragma block in one file rather than spreading settings across several. When no table file declares one, the defaults below apply.
Global Directives
| Directive | Type | Default | Description |
|---|---|---|---|
mode | string | "normal" | Validation mode: strict, normal, or relaxed |
optimization_level | int | 2 | Performance optimization level (0-3) |
experimental | bool | false | Enable experimental features |
deprecated_ok | bool | false | Allow deprecated features without errors |
errors_as_warnings | list | [] | Error types to downgrade to warnings |
disable_checks | list | [] | Validation checks to skip |
These six directives are the entire global surface. Caching, pre-allocation, and parallelism are configured per component, in the sub-blocks below.
Downgradable Error Types
These error types can be listed in errors_as_warnings:
undefined_variable– reference to an undefined variabletype_mismatch– type conversion errorsunused_variable– declared but never referenceddeprecated_usage– use of deprecated featuresoverflow_risk– potential integer overflowexpression_complexity– expression exceeds complexity limitsperformance_warning– a performance-related advisorystyle_warning– a style advisory
These checks cannot be downgraded or disabled:
syntax_error,circular_reference,duplicate_definition,missing_required
In strict mode, syntax_error, circular_reference, and duplicate_definition cannot be listed in disable_checks.
Validation Modes
strict
All validation errors are fatal. No undefined variables allowed. No implicit type conversions. All warnings promoted to errors. Maximum runtime safety checks.
normal
Syntax errors are fatal. Type mismatches produce warnings. Undefined variables default to zero values. Basic runtime safety checks. Suitable for active development.
relaxed
Only critical syntax errors are fatal. Minimal type checking. Maximum compatibility. Minimal runtime checks. Suitable for rapid prototyping.
Optimization Levels
| Level | Validation | Runtime | Safety |
|---|---|---|---|
| 0 | Full validation | No optimizations | Maximum safety |
| 1 | Full validation | Basic optimizations | High safety |
| 2 | Standard validation | Standard optimizations | Balanced |
| 3 | Minimal validation | Aggressive optimizations | Performance first |
Component Sub-Blocks
variables
| Directive | Type | Default | Description |
|---|---|---|---|
cache_expressions | bool | true | Cache variable expression results |
preallocate | bool | true | Pre-allocate variable storage |
type_checking | string | "normal" | Type checking strictness |
max_dependency_depth | int | 10 | Maximum variable dependency chain depth |
scoring
| Directive | Type | Default | Description |
|---|---|---|---|
mode | string | "normal" | Override validation mode for scoring |
optimization_level | int | 2 | Override optimization for scoring |
parallel_evaluation | bool | false | Enable parallel scoring evaluation |
cache_size_mb | int | 20 | Scoring expression cache size |
events
| Directive | Type | Default | Description |
|---|---|---|---|
mode | string | "normal" | Override validation mode for event handling |
parallel_processing | bool | false | Enable parallel event handling |
max_workers | int | 4 | Worker pool size for parallel events |
queue_size | int | 1000 | Event queue capacity |
timeout_ms | int | 5000 | Event processing timeout |
dedupe_window | string | "100ms" | Event deduplication window (duration string, e.g. "250ms") |
devices
| Directive | Type | Default | Description |
|---|---|---|---|
validation_level | string | "normal" | Device validation strictness |
debounce_defaults | bool | true | Apply default debounce to all switches |
These four sub-blocks — variables, scoring, events, and devices — are the
only ones a pragma block accepts. Noise behavior is configured per stream with
noise_context blocks instead.
Examples
Development Configuration
pragma {
mode = "normal"
experimental = true
errors_as_warnings = ["undefined_variable", "unused_variable"]
variables {
cache_expressions = false
}
scoring {
optimization_level = 1
}
}Production Configuration
pragma {
mode = "strict"
optimization_level = 2
variables {
cache_expressions = true
preallocate = true
type_checking = "strict"
}
scoring {
optimization_level = 3
parallel_evaluation = true
cache_size_mb = 50
}
events {
parallel_processing = false
}
devices {
debounce_defaults = true
validation_level = "strict"
}
}Gradual Migration
Move from legacy to strict validation in phases:
# Phase 1: Identify issues
pragma {
mode = "normal"
errors_as_warnings = ["undefined_variable", "type_mismatch", "deprecated_usage"]
}
# Phase 2: Fix critical issues, remove fixed error types
pragma {
mode = "normal"
errors_as_warnings = ["deprecated_usage"]
}
# Phase 3: Full strict mode
pragma {
mode = "strict"
}Fragment
Fragments are reusable bundles of configuration values that scoring rules pull in by name, so you define a value once and reference it from many rules. There are two kinds: static fragments hold fixed values, and dynamic fragments compute values from parameters supplied at the point of use.
Fragments are applied in score "event" rules through a fragments attribute.
Attributes
A fragment block takes two labels — the kind (static or dynamic) and the
name. A small set of attributes are structural; every other attribute you set
becomes a value the fragment supplies to the rules that reference it (for
example points).
| Attribute | Type | Description |
|---|---|---|
description | string | Optional human-readable note |
fragments | list | Other fragments to compose in (see Composition) |
params | block | Dynamic fragments only — declares typed parameters |
variables | map | Reserved structural attribute |
| (any other) | varies | A value supplied to referencing rules (e.g. points) |
description, fragments, params, and variables are reserved: they configure
the fragment itself and are never passed through as values. Any other attribute
name is.
fragment "static" "combo_scoring" {
description = "Shared combo-target award"
points = 2500
}Static fragments
A static fragment defines fixed values under a static label and a name:
fragment "static" "combo_scoring" {
points = 2500
}A scoring rule that references it inherits those values:
score "event" "combo_hit" {
when = device.combo_target.hit
fragments = ["static.combo_scoring"]
}The rule above awards 2500 points on every combo target hit, with the value
sourced from the fragment. Reference a static fragment by its qualified name
(static.<name>) as a bare string.
Dynamic fragments
A dynamic fragment declares params and computes its values from them. Computed
expressions use the expr( ... ) form:
fragment "dynamic" "scaled_points" {
params {
base = "int"
}
points = expr(var.base * 100)
}Supply the parameters where the fragment is referenced, using the object form of
a reference ({ name = ..., <param> = ... }):
score "event" "ramp_made" {
when = device.ramp.made
fragments = [
{ name = "dynamic.scaled_points", base = 50 },
]
}Here base = 50 resolves expr(var.base * 100) to 5000 points. Expressions
support arithmetic and functions such as min/max:
fragment "dynamic" "capped_combo" {
params {
combo = "int"
}
points = expr(min(var.combo * 1000, 5000))
}params may also be written in attribute form, which is equivalent:
fragment "dynamic" "scaled_points" {
params = { base = "int" }
points = expr(var.base * 100)
}Writing computed expressions
Use the expr( ... ) form for computed values. A bare ${ ... } is not
supported here: ${ ... } is the configuration language’s own interpolation
syntax, so it is evaluated when the file is loaded — before the fragment’s
parameters exist — and the value is dropped (Cade logs a warning). If you prefer
interpolation syntax, escape it as $${ ... }:
points = expr(var.base * 100) # preferred
points = "$${var.base * 100}" # equivalent (escaped interpolation)
points = "${var.base * 100}" # WRONG — evaluated too early, value is droppedThe expr( ... ) form works quoted or unquoted — expr(var.base * 100) and
"expr(var.base * 100)" behave identically.
Composition
A fragment can build on others by listing them in fragments. The composing
fragment inherits their values:
fragment "static" "base_award" {
points = 250
}
fragment "static" "ramp_award" {
fragments = ["static.base_award"]
}A rule that references static.ramp_award receives the inherited points = 250.
Override precedence
Values resolve in a clear order, from lowest to highest priority:
- Values from composed fragments
- Values from the referenced fragment
- Explicit values set directly on the scoring rule
So an explicit value on the rule always wins over a fragment-supplied one. No special syntax is needed — set the value directly on the rule:
fragment "static" "default_award" {
points = 100
}
score "event" "jackpot" {
when = device.jackpot.hit
points = 50000 # wins over the fragment's 100
fragments = ["static.default_award"]
}Examples
Shared scoring constant
Define a point value once and reuse it across rules:
fragment "static" "standard_target" {
points = 1000
}
score "event" "left_target" {
when = device.left_target.hit
fragments = ["static.standard_target"]
}
score "event" "right_target" {
when = device.right_target.hit
fragments = ["static.standard_target"]
}Parameterized award
One dynamic fragment, different values per rule:
fragment "dynamic" "ramp_bonus" {
params {
level = "int"
}
points = expr(var.level * 2500)
}
score "event" "ramp_level_1" {
when = device.ramp.made
condition = var.ramp_level == 1
fragments = [{ name = "dynamic.ramp_bonus", level = 1 }]
}
score "event" "ramp_level_3" {
when = device.ramp.made
condition = var.ramp_level == 3
fragments = [{ name = "dynamic.ramp_bonus", level = 3 }]
}Variable
Variables hold game state values that change during gameplay. Each variable has an explicit type, an initial value, and a scope that determines its lifetime. Variables are referenced in expressions throughout the configuration using the var. prefix.
Declaration Syntax
A variable block requires two labels: the type and the name. The body contains the initial value and optional constraints.
variable "int" "bumper_value" {
initial = 1000
min = 100
max = 10000
scope = "player"
}| Property | Type | Required | Description |
|---|---|---|---|
initial | (varies) | No | Starting value; should match the variable type |
min | number | No | Minimum bound (numeric types) |
max | number | No | Maximum bound (numeric types) |
scope | string | No | Lifetime scope (see Scopes; default: game) |
formula | string | No | Computed-variable expression (implies computed = true) |
computed | bool | No | Mark as computed (default: false) |
decay_rate | number | No | Continuous decay per second toward decay_to |
decay_to | number | No | Floor value for decay; uses min if omitted |
String, list, and map constraints are additional flat attributes on the same block — see the constraint sections below.
Types
| Type | Default Value | Description |
|---|---|---|
int | 0 | Scoring values, counters, numeric calculations |
float | 0.0 | Multipliers, physics, fractional values |
bool | false | Flags, states, conditions |
string | "" | Text display, clip names, identifiers |
list | [] | Collections of typed elements |
map | {} | Key-value associations |
distribution | (none) | Weighted random selection, defined by buckets |
Integer Constraints
variable "int" "bumper_value" {
initial = 1000
min = 100 # Minimum allowed value
max = 10000 # Maximum allowed value
}Float Constraints
variable "float" "ramp_multiplier" {
initial = 1.0
min = 0.5
max = 10.0
}Float variables accept integer values and promote them automatically.
String Constraints
String constraints are flat attributes written directly on the variable
block — not a nested constraints {} sub-block:
variable "string" "player_code" {
initial = "ABC"
scope = "player"
min_length = 2 # Minimum characters
max_length = 8 # Maximum characters
pattern = "^[A-Z0-9]+$" # Regex the value must match
allowed_values = ["ABC", "XYZ"] # Whitelist of valid values
case_sensitive = false # Compare values case-insensitively
}| Attribute | Type | Description |
|---|---|---|
min_length | int | Minimum number of characters |
max_length | int | Maximum number of characters |
pattern | string | Regular expression the value must match |
allowed_values | list | Whitelist of permitted values |
case_sensitive | bool | Whether value comparisons are case-sensitive |
List and Map Constraints
List and map constraints are also flat attributes on the variable block —
there is no nested operations {} sub-block. max_size and min_size apply to
both lists and maps; the remaining attributes are list-only:
variable "list" "combo_tags" {
initial = ["ramp", "loop"]
scope = "global"
max_size = 10 # Maximum elements
min_size = 1 # Minimum elements
unique_elements = true # Reject duplicate values
allow_empty = false # Reject an empty list
sorted = true # Keep elements sorted
}| Attribute | Type | Applies to | Description |
|---|---|---|---|
max_size | int | list, map | Maximum number of elements |
min_size | int | list, map | Minimum number of elements |
unique_elements | bool | list | Reject duplicate elements |
allow_empty | bool | list | Whether an empty list is permitted |
sorted | bool | list | Keep elements in sorted order |
Map Variables
variable "map" "mode_scores" {
initial = {
normal = 1000
multiball = 5000
wizard = 10000
}
scope = "player"
}Distribution Variables
A distribution variable performs weighted random selection. Instead of an
initial value, its body holds a buckets block; each bucket has a relative
weight, an integer value, and a label. Selecting the variable draws a
bucket at random, with higher weights chosen proportionally more often:
variable "distribution" "jackpot_fanfare" {
scope = "player"
buckets {
bucket {
weight = 60
value = 0
label = "normal_fanfare"
}
bucket {
weight = 25
value = 1
label = "extended_fanfare"
}
bucket {
weight = 10
value = 2
label = "epic_fanfare"
}
bucket {
weight = 5
value = 3
label = "legendary_fanfare"
}
}
}| Bucket attribute | Type | Required | Description |
|---|---|---|---|
weight | int | Yes | Relative selection weight; must be positive |
value | int | No | Integer result when this bucket is drawn |
label | string | Yes | Human-readable name for the bucket |
A bucket missing its label, or with a zero or negative weight, is skipped
rather than aborting the variable — the remaining buckets still load.
Scopes
Variables have a scope that controls their lifetime and reset behavior:
| Scope | Lifetime | Reset Trigger | Shared Across Players |
|---|---|---|---|
global | Persists across all games | Power cycle | Yes |
session | Lasts for one game, shared by all players | Game start / game end | Yes |
game | Persists for one complete game | Game start | Yes |
player | Persists for one player’s game | Player start | No |
ball | Lasts for one ball | Ball start | No |
If scope is omitted, the variable is unscoped and behaves as game-scoped —
it resets at game start.
Scope Lifecycle
- Ball start: All
ball-scoped variables reset to their initial values. Player, session, and game variables are unchanged. - Player turn start: On a player’s first turn, both
playerandballvariables initialize to defaults. On subsequent turns, onlyballvariables reset. - Game end:
player,ball, andsessionvariables reset to their initial values.global-scoped variables are preserved. Resets happen during the game-over phase — variable-driven lights and other dependent state clear immediately, not at the next game start. - Game start:
player,ball, andsessionvariables reset again (idempotent safety net).
Session vs Game Scope
The session scope fills a gap between global (persists forever) and player (per-player turn). Session variables reset at game boundaries but are shared across all players within a game — they are not player-keyed. Use session for state that should survive player switches but clear when the game ends.
# Resets every ball
variable "bool" "skill_shot_active" {
initial = true
scope = "ball"
}
# Persists across balls for one player
variable "int" "bonus_multiplier" {
initial = 1
scope = "player"
max = 10
}
# Shared across players, resets each game
variable "int" "table_bonus_level" {
initial = 0
scope = "session"
max = 5
}
# Persists across all games
variable "int" "high_score" {
initial = 0
scope = "global"
}Computed Variables
Computed variables derive their value from an expression referencing other variables. They recalculate automatically when dependencies change. Setting formula marks the variable computed even without an explicit computed = true:
variable "int" "ramp_progressive_value" {
formula = var.ramp_base_value + (var.ramp_count * 1000)
computed = true
}
variable "int" "total_multiplier" {
formula = var.base_multiplier * var.mode_multiplier
computed = true
}Change Events
When a variable’s value changes, Cade emits a variable.<name>.changed event. This allows event handlers to react to variable mutations from any source — gameplay actions, lifecycle resets, or player switches.
Event Shape
| Field | Type | Description |
|---|---|---|
name | string | Variable name |
old | (varies) | Previous value |
new | (varies) | New value |
scope | string | Variable scope (global, session, player, etc.) |
reason | string | What caused the change |
The reason field indicates the mutation source:
| Reason | Description |
|---|---|
set | Explicit set_variable action in an event handler |
toggle | toggle_variable action |
reset | Lifecycle reset (game end, ball start, etc.) |
player_switch | Active player changed; player-scoped value now differs |
Events are only emitted when the value actually changes — a set_variable that writes the same value produces no event.
Synchronizing Lights with Variables
The change event is particularly useful for keeping physical lights in sync with variable state. Without it, lights set by an event handler would go stale on player switches and game-end resets — the variable resets but the light stays in its last state.
An assembly can opt into synchronization by listening for the change event:
assembly "rollover_logic" {
parameter "int" "switch_id" { required = true }
parameter "int" "light_id" { required = true }
parameter "int" "points" { default = 5000 }
device "switch" "standard" "rollover" {
id = param.switch_id
}
device "light" "standard" "lamp" {
id = param.light_id
}
variable "bool" "lit" {
initial = false
scope = "player"
}
score "event" "rollover_scored" {
when = device.self.rollover.hit
points = param.points
}
event_handler "turn_on" {
when = device.self.rollover.hit
condition = var.self.lit == false
actions {
toggle_variable = "self.lit"
set_light "self.lamp" {
state = "on"
}
}
}
event_handler "turn_off" {
when = device.self.rollover.hit
condition = var.self.lit == true
actions {
toggle_variable = "self.lit"
set_light "self.lamp" {
state = "off"
}
}
}
# Re-sync light state when the variable changes for any reason
# (game-end reset, player switch, external set)
event_handler "sync_on" {
when = variable.self.lit.changed
condition = var.self.lit == true
actions {
set_light "self.lamp" {
state = "on"
}
}
}
event_handler "sync_off" {
when = variable.self.lit.changed
condition = var.self.lit == false
actions {
set_light "self.lamp" {
state = "off"
}
}
}
}Inside an assembly, an instance addresses its own generated blocks with the
self prefix — device.self.rollover.hit, var.self.lit — so three instances
of this assembly never cross-talk. See
Assembly.
With this pattern:
- Game end: the
player-scopedlitresets tofalse→ the change event fires →sync_offturns the physical light off - Player switch: P1 has
lit = true, P2 haslit = false→ change event fires → the light reflects the new player’s state - Player restore: when P1’s turn resumes,
litrestores totrue→ the light turns back on
Expression Syntax
Variable References
Variables are referenced with the var. prefix. Expression-bearing attributes
take a bare expression — write it directly, with no quotes or wrapper:
points = var.bumper_value
condition = var.skill_shot_activeExpressions with operators or multiple variables are written the same way — bare:
points = var.base_value * var.multiplier
condition = var.loop_count >= 3The fully-quoted form (condition = "var.loop_count >= 3") still parses for
back-compat, but bare is canonical. The old ${ ... } interpolation form is
no longer accepted in expression fields and is rejected at load.
Operators
# Arithmetic
+ - * / % # Basic math
** # Exponentiation
# Comparison
== != < > <= >= # Comparisons
# Logical
&& || ! # Boolean logic
# Ternary
condition ? true_value : false_value
Built-in Functions
min(a, b, ...) # Minimum value
max(a, b, ...) # Maximum value
abs(x) # Absolute value
clamp(value, min, max) # Constrain to range
round(x, [precision]) # Round to nearest, optional decimal places
floor(x) # Round down
ceil(x) # Round up
sqrt(x) # Square root
pow(base, exponent) # Exponentiation
There is no conditional function — use the ternary operator (condition ? a : b)
instead.
Random, statistical, and noise functions (random, random_range,
weighted_choice, normal, uniform, noise, perlin, …) are also
available; see Noise Context for
the seedable ones. String and list helpers are namespaced — string.length,
string.concat, list.length, list.append, list.random, and so on.
Examples
Integer Percentage Multipliers
Cade uses integer-based percentage multipliers to avoid floating-point precision issues. A value of 100 represents 1.0x:
variable "int" "combo_multiplier" {
initial = 100 # 100% = 1.0x
min = 100 # Never below 1.0x
max = 1000 # Cap at 10.0x
scope = "ball"
}
score "event" "bumper_hit" {
when = device.bumper.hit
points = (var.bumper_value * var.combo_multiplier) / 100
update {
combo_multiplier = min(var.combo_multiplier + 50, 1000)
}
}Decaying Variables
Continuous decay uses decay_rate (a per-second amount) and produces smooth
value changes toward decay_to:
variable "float" "combo_multiplier" {
initial = 1.0
min = 1.0
max = 10.0
scope = "player"
decay_rate = 0.5 # Lose 0.5 per second
decay_to = 1.0 # Stop decaying at 1.0
}For int variables, decay_rate amounts are truncated to integer before application. Decay always respects both decay_to and the variable’s min constraint — whichever is higher acts as the effective floor.
Map Access in Expressions
variable "map" "mode_scores" {
initial = {
normal = 1000
multiball = 5000
}
scope = "player"
}
# Access map value by key
score "event" "mode_bonus" {
when = device.target.hit
points = var.mode_scores[var.current_mode]
}Constant
A constant is a named, fixed value you define once and reference throughout your configuration by name. Unlike a variable, a constant never changes at runtime — it has no scope, no bounds, and no per-ball or per-player lifetime. Use constants for tuning values that several rules share: a base point award, a timer duration, a feature flag.
constant "int" "jackpot_base" {
value = 50000
description = "Starting jackpot value, shared across modes"
}Syntax
A constant block takes two labels — the value type and the name:
constant "<type>" "<name>" {
value = <value>
}Types
| Type | Holds |
|---|---|
int | A whole number |
bool | true or false |
string | Text |
list | A list of values |
Attributes
| Property | Type | Required | Description |
|---|---|---|---|
value | (varies) | Yes | The constant’s value; must match the declared type |
description | string | No | Human-readable note describing the constant |
A constant with no value is a configuration error.
Examples
Shared tuning values
constant "int" "ball_save_ms" {
value = 15000
description = "Ball-save grace period in milliseconds"
}
constant "bool" "tournament_mode" {
value = false
}
constant "string" "default_music" {
value = "main_theme"
}Reference a constant from any expression by its name, the same way you reference a variable.
Inside an assembly or module
Constants can be declared at the top level, inside an assembly body, or inside a module. A constant declared inside a module is scoped to that module.
module "multiball" {
constant "int" "starting_balls" {
value = 3
}
}Score
The scoring system defines how points are awarded, modified, and accumulated during gameplay. Scoring rules compile ahead of play, so awarding points during a game stays fast and predictable. Scoring configuration uses three block types: score "event" for event-triggered point awards, score "modifier" for conditional variable changes, and score "accumulator" for special-purpose score tracking like combos and jackpots.
Score Event
Score events define point awards triggered by game events. A score event block takes the label "event" and a unique name. It fires whenever the event named by when occurs and its condition (if any) evaluates true.
score "event" "ramp_complete" {
when = device.ramp.cleared
condition = var.multiball_active
points = var.ramp_base_value * var.ramp_count
priority = 50
emit = ramp_scored
update {
ramp_count = var.ramp_count + 1
}
}Properties
| Property | Type | Default | Description |
|---|---|---|---|
when | string | required | Event that activates this rule — see Naming the event |
condition | expression | true | Boolean expression that must be true to score |
points | expression | required | Expression calculating the points to award |
priority | int | 100 | Execution order (lower runs first) |
enabled | bool | true | Whether this rule is active |
emit | string | (none) | Event emitted after scoring completes |
fallback_points | int | (none) | Points awarded if the points expression cannot evaluate |
fragments | list | (none) | Fragments this rule reuses |
triggeris an accepted synonym forwhen. Older tables commonly bind the event withtrigger =, and those load.whenis canonical — prefer it in new rules. If a rule carries both,whenwins deterministically.
Naming the event
A device event is written in three parts:
device.<identifier>.<action>
The identifier is either a device name or a device subtype — the second label of a device declaration. This is the part most worth internalizing, because a subtype scores across a whole class of devices without writing one rule per device.
Given these declarations:
device "coil" "bumper" "bumper_1" { id = 10 }
device "coil" "bumper" "bumper_2" { id = 11 }
device "coil" "bumper" "bumper_3" { id = 12 }both of these are valid, and they mean different things:
# Subtype — fires for ANY bumper. One rule covers all three.
score "event" "bumper_hit" {
when = device.bumper.activated
points = 1000
}
# Device name — fires only for that one bumper.
score "event" "left_bumper_hit" {
when = device.bumper_1.activated
points = 2500
}There is no syntax marking which kind you wrote. device.bumper.activated
looks exactly like a device reference, so a rule bound to a subtype reads as
though it targets a device that does not exist. It resolves, and it fires.
Precedence. A device’s own name always wins outright — a name can never be shadowed by a tag or a subtype, because a name is the only identifier guaranteed unique. A tag resolves only when exactly one device declares it; a tag shared by several devices is deliberately excluded from this lookup rather than resolving to an arbitrary one.
Event actions
The action is the third segment, and it comes from a fixed vocabulary. The mapping below is what the Visual Pinball driver understands:
| Action | Fires on |
|---|---|
activated, hit, entered, captured | hit, slingshot, on, pressed, dropped |
deactivated, unhit, exited, released | unhit, off, released, raised |
spin | spinner rotation |
pressed | pressed, hit, end-of-stroke |
depressed, unpressed | released, unhit, beginning-of-stroke |
dropped | drop-target dropped, hit |
raised | drop-target raised, unhit |
cleared | hit, slingshot |
An unrecognized action silently falls back to
hit. A typo such asdevice.spinner.spinndoes not fail to load and does not warn — it quietly binds to the hit event instead. If a rule fires on the wrong edge, or fires when you expected nothing, check the action spelling against the table above first.
Nested blocks
An update block assigns new values to variables after the award is applied — the right-hand side is an expression evaluated with current game state in scope:
update {
ramp_count = var.ramp_count + 1
combo_multiplier = min(var.combo_multiplier + 50, 1000)
}A structured condition block accepts all_of / any_of lists as an alternative to the scalar condition attribute:
condition {
any_of = [
var.combo_multiplier >= 300,
mode.wizard.active,
]
}To make a scoring rule play a sound, author a top-level route{} (and, if the
rule has more than one candidate clip, a selection{}) that names the rule as
its owner. A nested audio {} block under a score block no longer parses:
route {
owners = [score.ramp_complete]
source "audio_clip" "ramp_award" { weight = 1 }
}Score Modifier
Modifiers change variable values based on conditions or timers. They do not award points directly. A score modifier block takes the label "modifier" and a unique name.
score "modifier" "multiball_bonus" {
type = "conditional"
condition = mode.multiball.active
apply {
bumper_value = var.bumper_value * 2
}
}Properties
| Property | Type | Default | Description |
|---|---|---|---|
type | string | "conditional" | Modifier type: conditional or timer |
condition | expression | (none) | Boolean guard for conditional modifiers |
when | string | (none) | Event that activates this modifier |
interval | duration | (none) | Update interval for timer modifiers |
priority | int | 100 | Execution order (lower runs first) |
enabled | bool | true | Whether this modifier is active |
The variable changes go in an apply block (modify is accepted as a synonym).
A modifier makes sound the same way a score event does — a top-level route{}
with owners = [modifier.<name>].
Examples
# Conditional modifier: doubles values during multiball
score "modifier" "multiball_bonus" {
condition = mode.multiball.active
apply {
bumper_value = var.bumper_value * 2
slingshot_value = var.slingshot_value * 2
base_multiplier = 200
}
}
# Timer modifier: decays combo multiplier every second
score "modifier" "combo_decay" {
type = "timer"
interval = "1s"
apply {
combo_multiplier = max(var.combo_multiplier - 10, 100)
}
}
# Event-triggered modifier: resets state on ball start
score "modifier" "ball_start_reset" {
when = game.ball_start
apply {
skill_shot_active = true
combo_multiplier = 100
ramp_count = 0
}
}Score Accumulator
Accumulators track score across multiple events using patterns like sequences, progressive jackpots, and thresholds. A score accumulator block takes the label "accumulator" and a unique name.
score "accumulator" "bumper_combo" {
type = "sequence"
events = ["bumper.*"]
window = "5s"
on_collect = game.combo_collected
collect_multiplier = var.combo_multiplier
reset_on_collect = true
on_increment {
update {
combo_count = "var.combo_count + 1"
}
}
on_break {
update {
combo_count = "0"
}
}
}Accumulator expressions must be quoted. Unlike
score "event"andscore "modifier"— wherepoints,condition, andupdatevalues are written bare — an accumulator reads its expression-valued fields as plain values. A bare reference such ascollect_multiplier = var.combo_multiplierresolves, but anything containing an operator or a call (var.combo_count + 1,score_event.value >= 5000) does not: it is silently dropped and the field behaves as if it were never set. Write those in quotes. This applies tofilter,contribute_when,collect_multiplier, and every value inside anon_increment/on_breakupdate {}block — including bare numbers, so usecombo_count = "0", notcombo_count = 0.
Accumulator Types
| Type | Description |
|---|---|
sequence | Tracks ordered or windowed event patterns |
progressive | Builds value from a percentage of qualifying scores |
threshold | Accumulates until a threshold is reached, then collectable |
Properties
| Property | Type | Applies To | Description |
|---|---|---|---|
type | string | all | Accumulator type: sequence, progressive, or threshold |
window | duration | sequence | Time window for event matching |
pattern | list | sequence | Ordered event pattern to match |
events | list | sequence | Event glob patterns to collect |
filter | expression | sequence | Expression a candidate event must satisfy to count |
contribute_percent | int | progressive | Percentage of each qualifying score to add |
contribute_when | expression | progressive | Condition for score contribution |
threshold | int | threshold | Value that triggers collectability |
reset_on_collect | bool | all | Reset accumulated value after collection |
on_collect | string | all | Event that triggers collection |
collect_multiplier | expression | all | Multiplier applied at collection time |
Nested blocks
An on_increment block runs each time the accumulator advances, and on_break runs when a sequence is interrupted. Each holds an update block that assigns variables — quoted, per the note above:
on_increment {
update {
combo_count = "var.combo_count + 1"
}
}An accumulator makes sound the same way a score event does — a top-level
route{} with owners = [accumulator.<name>].
Examples
Basic Scoring
variable "int" "bumper_value" {
initial = 1000
min = 100
max = 10000
}
variable "int" "slingshot_value" {
initial = 500
min = 100
max = 5000
}
score "event" "bumper_hit" {
when = device.bumper.hit
points = var.bumper_value
}
score "event" "slingshot_hit" {
when = device.slingshot.hit
points = var.slingshot_value
}Progressive Scoring
variable "int" "combo_multiplier" {
initial = 100
min = 100
max = 1000
scope = "ball"
}
variable "int" "ramp_count" {
initial = 0
scope = "ball"
}
variable "int" "ramp_base_value" {
initial = 5000
}
score "event" "ramp_shot" {
when = device.ramp.cleared
points = (var.ramp_base_value + (var.ramp_count * 1000)) * var.combo_multiplier / 100
update {
ramp_count = var.ramp_count + 1
combo_multiplier = min(var.combo_multiplier + 50, 1000)
}
}Sequence Accumulator
variable "int" "combo_count" {
initial = 0
scope = "ball"
}
score "accumulator" "bumper_combo" {
type = "sequence"
events = ["bumper.*"]
window = "5s"
on_increment {
update {
combo_count = "var.combo_count + 1"
}
}
on_break {
update {
combo_count = "0"
}
}
}Progressive Jackpot
score "accumulator" "super_jackpot" {
type = "progressive"
contribute_percent = 5
contribute_when = "score_event.value >= 5000"
on_collect = game.super_jackpot_collected
collect_multiplier = var.combo_multiplier
reset_on_collect = true
}Signal
A signal detects a pattern of device activity — an ordered shot through a sequence of switches, a combo of completed shots, or a bank of targets all knocked down — and emits a completion event when the pattern is satisfied. Other rules (scoring, event handlers, modes) subscribe to that event.
signal "shot" "left_orbit" {
switches = [orbit_entry, orbit_exit]
time_window = "3s"
points = 2000
}When the ball trips orbit_entry then orbit_exit within three seconds, the
signal completes and awards 2000 points.
Syntax
A signal block takes two labels — the signal type and the name:
signal "<type>" "<name>" {
# ...
}Signal types
| Type | Detects |
|---|---|
shot | An ordered sequence of switch hits (a ramp, orbit, or lane) |
combo | A series of other completed signals, in order, within a window |
bank | A group of targets all in the down state |
Attributes
| Property | Type | Applies to | Description |
|---|---|---|---|
switches | list | shot | Ordered switch references the ball must trip. Write them bare — the name each switch was declared with; a device.-qualified form (e.g. device.orbit_entry) also resolves |
signals | list of strings | combo | Ordered completion-event names that make up the combo |
all_down | list of strings | bank | Target device references that must all be down |
points | int or string | all | Points awarded on completion; a string is evaluated as an expression |
each_switch_points | int or string | shot | Additional points awarded per switch in the sequence; same int-or-string rule as points |
time_window | string | shot,combo | Duration in which the pattern must complete, e.g. "3s", "500ms" |
reversible | bool | shot | Whether the shot also completes when tripped in reverse order |
enabled | string | all | Expression gating the signal; when it evaluates false, the signal is inert |
Quote
pointsandeach_switch_pointswhen they compute. A plain number (points = 2000) works bare, and so does a lone reference (points = var.jackpot). But an expression containing an operator or a call must be written as a quoted string —points = "25000 * var.combo_multiplier". Left bare, it is silently dropped and the signal awards nothing.enabledis the exception: it accepts a bare expression (enabled = var.tournament == false).
Completion handlers
A signal may carry nested action blocks that fire at lifecycle points:
| Block | Fires when |
|---|---|
on_complete | The pattern completes successfully |
on_progress | An intermediate step of the pattern is reached |
on_timeout | The time_window elapses before completion |
signal "combo" "super_combo" {
signals = [shot.left_ramp.complete, shot.left_orbit.complete]
time_window = "5s"
on_complete {
emit = combo.super_combo.complete
}
}A signal may also carry a validation block that rejects physically impossible
input. Its min_time attribute sets the shortest allowed gap between steps — a
sequence completed faster than this is discarded:
signal "shot" "left_ramp" {
switches = [ramp_entry, ramp_exit]
validation {
min_time = "80ms"
}
}Completion events
Each signal emits a completion event named <type>.<name>.complete — for example
shot.left_orbit.complete or bank.drop_targets.complete. Subscribe to it from a
scoring rule or event handler:
score "event" "orbit_award" {
when = shot.left_orbit.complete
points = 5000
}To emit a custom event name instead, set emit inside on_complete.
Examples
One-way ramp shot
A shot that only counts in the forward direction:
signal "shot" "left_ramp" {
switches = [ramp_entry, ramp_exit]
reversible = false
points = 1500
}Drop-target bank
Complete when every drop target in the bank is down:
signal "bank" "drop_targets_complete" {
all_down = ["drop_1", "drop_2", "drop_3"]
points = 10000
}Expression-valued points
Scale the award by a runtime multiplier. An expression that contains an operator must be quoted — see the note under Attributes:
signal "combo" "mega_combo" {
signals = [shot.left_ramp.complete, shot.center_ramp.complete, shot.right_ramp.complete]
time_window = "10s"
points = "25000 * var.combo_multiplier"
}Replay
A replay awards the player something — a credit or an extra ball — when their
score crosses a threshold, the classic “free game for a high score” of mechanical
and solid-state pinball. Define the thresholds once with a top-level replay
block and the runtime grants the award and fires an event each time a player
passes one.
replay {
max_per_game = 1
threshold {
score = 5000000
reward = "credit"
}
threshold {
score = 15000000
reward = "extra_ball"
}
}Syntax
The replay block takes no label — a table has one:
replay {
max_per_game = <n>
threshold {
score = <n>
reward = "<type>"
}
# ... more thresholds
}Attributes
| Property | Type | Required | Description |
|---|---|---|---|
max_per_game | int | No | Caps how many replay awards a single game may grant. Omit for no cap |
Thresholds
Each threshold block defines one score level and the reward granted when a
player’s score crosses it. List as many as you need; they are evaluated
independently.
| Property | Type | Required | Description |
|---|---|---|---|
score | int | Yes | The score a player must reach to earn the reward |
reward | string | Yes | The award type: "credit" or "extra_ball" |
reward must be "credit" or "extra_ball" — any other value is a configuration
error.
Award event
When a player crosses a threshold, the runtime grants the reward and emits a
system.replay.award event. Subscribe to it from an
event handler to react — flash a
lamp, play a fanfare, or update a display:
event_handler "replay_celebration" {
when = system.replay.award
actions {
flash_light "replay_lamp"
}
}Examples
Single replay at a fixed score
replay {
max_per_game = 1
threshold {
score = 2000000
reward = "credit"
}
}Tiered rewards
Award a credit at the first level and an extra ball at a higher one, with no per-game cap:
replay {
threshold {
score = 1000000
reward = "credit"
}
threshold {
score = 3000000
reward = "extra_ball"
}
}Global Events
The global_events block declares the table-wide events your machine
recognizes, optionally giving each one a priority. Think of it as a catalog —
one place to name the events that flow through the game (flipper presses,
plunger fires, nudges, custom game events).
It does not define what happens when an event fires. A global event performs no action on its own; declaring it registers the name (and priority) with the engine. To react to an event, pair it with an event handler or a scoring rule.
global_events {
left_flipper_press {
description = "Left flipper button pressed"
priority = 100
}
plunger_fire {
description = "Plunger released"
}
}Syntax
Each event is a block whose name is the event name — there is no quoted label and no leading keyword:
global_events {
<event_name> {
description = "<text>" # optional
priority = <int> # optional
}
# ... more events ...
}Attributes
Each event entry accepts two optional keys; any other key is ignored.
| Property | Type | Required | Description |
|---|---|---|---|
description | string | No | Human-readable note describing the event. For documentation only — it has no runtime effect. |
priority | int | No | Orders this event relative to others. Defaults to 100. |
Examples
Cataloging input events
The common use is a single block that names the physical inputs and physics
events a table produces, so they read as one catalog. priority is optional —
omit it and the event defaults to 100:
global_events {
# --- Flipper control ---
left_flipper_press {
description = "Left flipper button pressed"
priority = 100
}
left_flipper_release {
description = "Left flipper button released"
priority = 100
}
left_flipper_collide {
description = "Ball collided with left flipper"
}
# --- Plunger ---
plunger_fire {
description = "Plunger released"
}
}Event naming conventions
Events follow a hierarchical dot-separated naming pattern:
| Prefix | Description | Examples |
|---|---|---|
switch.* | Hardware switch events | switch.bumper_1, switch.trough_3 |
device.* | Device hardware events (auto-generated) | device.left_spinner.spin |
signal.* | Signal pattern detection events | signal.shot.left_orbit.complete |
variable.* | Variable change events (auto-generated) | variable.l6_lit.changed |
mode.* | Game mode events | mode.multiball.active |
player.* | Player-specific events | player.score_changed |
system.* | System events | system.game_start |
combo.* | Combination/sequence events | combo.ramp_combo |
Device vs signal suffixes
Device events use action-specific suffixes based on device type:
| Device Type | Suffix | Example |
|---|---|---|
| spinner | .spin | device.left_spinner.spin |
| target | .hit | device.center_target.hit |
| bumper | .hit | device.pop_bumper.hit |
| slingshot | .hit | device.left_slingshot.hit |
| button | .hit | device.launch_button.hit |
| rollover | .rollover | device.top_rollover.rollover |
| loop | .cleared | device.left_loop.cleared |
| ramp | .cleared | device.center_ramp.cleared |
| orbit | .cleared | device.right_orbit.cleared |
| opto | .cleared | device.ball_gate.cleared |
| gate | .cleared | device.outlane_gate.cleared |
| trough | .cleared | device.trough_1.cleared |
| switch | .activated | device.generic_switch.activated |
Coils, lights, flippers, and ball devices produce no device event of their own.
Signal events use the .complete suffix for higher-level pattern detection:
signal.shot.left_orbit.complete
signal.combo.super_jackpot.complete
Variable change events
Variable events use the pattern variable.<name>.changed and are emitted
automatically whenever a variable’s value changes. The event carries a payload
with name, old, new, scope, and reason fields. See
Variable — Change Events for the full event shape and
usage examples.
variable.l6_lit.changed # Boolean rollover light state changed
variable.bonus_multiplier.changed # Player multiplier updated
Related blocks
| Block | Use it to |
|---|---|
event_handler | Run actions in response to an event |
score | Award points or update variables on an event |
flow | Orchestrate relationships between events over time |
Legacy aliases
global_events is the current spelling. Two older forms still parse for
backward compatibility and behave identically:
global_event { ... }— singular block nameevent { ... }— bare singular
Prefer global_events in new tables.
Event Flow
A flow block orchestrates relationships between events over time — a sequence
that must occur in order, a set that can be completed in any order, and so on.
When the flow’s condition is met it fires an on_complete callback; if a time
window is set and expires first, it fires on_timeout.
flow "sequence" "skill_shot" {
events = ["ball_plunged", "upper_loop", "skill_target"]
window = "3s"
on_complete {
emit = skill_shot_made
points = 25000
}
on_timeout {
emit = skill_shot_missed
}
}Syntax
A flow block takes two labels — its type and its name:
flow "<type>" "<name>" {
events = ["<event>", ...] # events the flow watches
window = "<duration>" # optional time limit, e.g. "3s"
on_complete { ... }
on_timeout { ... }
}Flow types
The first label selects the matching strategy:
| Type | Matches when |
|---|---|
sequence | the listed events occur in the given order |
parallel | every listed event occurs, in any order |
conditional | the listed events occur while prerequisites hold |
timed | the events occur within the window |
state_machine | the events drive a multi-state progression |
accumulator | repeated matches accumulate toward a target |
An unknown type is a parse error.
Attributes
| Property | Type | Required | Description |
|---|---|---|---|
events | list | No | The events the flow watches, in order for a sequence |
window | duration | No | Time limit as a Go duration string ("3s", "30s", "500ms"). When it expires before completion, on_timeout fires |
Blocks
| Block | Description |
|---|---|
on_complete | Fires when the flow completes |
on_timeout | Fires when window expires before the flow completes |
Each callback accepts:
| Property | Type | Description |
|---|---|---|
emit | event name | Event to emit when the callback fires |
points | expression | Points to award; may be a runtime expression (var.jackpot_value * 5) |
Examples
Sequence flow
Events must occur in a specific order within a time window:
flow "sequence" "skill_shot" {
events = ["ball_plunged", "upper_loop", "skill_target"]
window = "3s"
on_complete {
emit = skill_shot_made
points = 25000
}
on_timeout {
emit = skill_shot_missed
}
}Parallel flow
Events can occur in any order to complete a set:
flow "parallel" "drop_target_bank" {
events = ["drop_1", "drop_2", "drop_3", "drop_4"]
window = "30s"
on_complete {
emit = bank_cleared
points = 10000
}
}Related blocks
| Block | Use it to |
|---|---|
global_events | Declare the events a flow watches and emits |
event_handler | Run actions in response to an event |
score | Award points or update variables on an event |
Event Handler
An event handler runs a block of actions in response to an event. Where a scoring rule is built around awarding points, an event handler is the general-purpose reaction: pulse a coil, set a variable, play a sound, emit another event.
event_handler "launch_ball" {
when = device.plunger.released
actions {
pulse_coil "trough_eject" {}
}
}Syntax
An event handler takes one label — its name:
event_handler "<name>" {
when = "<event>"
# condition, actions, audio ...
}Attributes
| Property | Type | Required | Description |
|---|---|---|---|
when | string | Yes | The event that triggers the handler |
signal | string | — | Alias for when; reads naturally when triggering on a signal completion |
event | string | — | Alias for when |
condition | string | No | Expression gate; the actions run only when it evaluates true |
when, signal, and event are synonyms for the trigger — use whichever reads
best. Set only one.
Blocks
| Block | Description |
|---|---|
actions | The actions to run when the handler fires (see below) |
audio | Audio to play when the handler fires |
Actions
The actions block holds action verbs. Two kinds, with two syntaxes:
- Block verbs target a device and take braces:
pulse_coil "<device>" {},flash_light "<device>" {},set_light "<device>" {},set_variable {},eject_ball {},move_servo {}. - Attribute verbs take a value with
=:emit = "<event>",increment = "<variable>",decrement = "<variable>",toggle_variable = "<variable>",delay = "<duration>",points = <number>.
Action verbs may also be written directly in the handler body without an
enclosing actions block; both forms are equivalent:
# Wrapped form
event_handler "score_target" {
when = device.standup.hit
actions {
increment = "targets_hit"
emit = "target_progress"
}
}
# Bare form — equivalent
event_handler "score_target" {
when = device.standup.hit
increment = "targets_hit"
emit = "target_progress"
}Examples
Conditional reaction
Run only when a guard expression holds:
event_handler "auto_relaunch" {
when = ball_drained
condition = var.balls_in_play == 0 && !game.ball_ended
actions {
delay = "1s"
pulse_coil "trough_eject" {}
}
}Triggering on a signal
The signal alias documents intent when the trigger is a signal completion:
event_handler "orbit_lights" {
signal = shot.left_orbit.complete
actions {
flash_light "orbit_arrow" {}
}
}Scoping handlers to a mode
Event handlers can be declared at the top level or inside a
module. A handler inside a module is active
while that module is. For reactions that should only fire while a mode runs, the
module’s mode { events { on "<event>" { ... } } } block is the mode-scoped
equivalent.
Audio
Cade plays recorded sound through clips — .wav or .ogg files you declare
once and trigger from gameplay. This page is the block reference for declaring
clips, triggering them, driving device audio, routing sound through mixer
channels, and scoring background music. For a walkthrough of attaching sound to
devices and scoring, and for real-time synthesis, see the
Audio & Sound guide and
Synth.
Declaring a clip
A top-level audio_clip block defines a named sound. It takes one label — the
name you reference elsewhere:
audio_clip "fx_flipper" {
file = "sounds/fx_flipperup.wav"
preload = true
volume = 0.67
}| Property | Type | Default | Description |
|---|---|---|---|
file | string | — | Path to the audio file, relative to the table directory |
preload | bool | false | Load the file at startup instead of on first play |
volume | float | 1.0 | Per-clip volume multiplier (0.0–1.0) |
priority | int | 500 | Mixer priority when voices compete; higher wins |
loop | bool | false | Repeat the clip until stopped |
Triggering a clip
Scoring rules and event handlers
play a clip through a nested audio block. Reference the clip by name with clip:
score "event" "jackpot" {
when = device.center_ramp.made
points = 100000
audio {
clip = jackpot_fanfare
volume = 0.9
}
}| Property | Type | Description |
|---|---|---|
clip | string | Name of the audio_clip to play |
volume | float | Volume multiplier for this trigger |
priority | int | Mixer priority for this trigger |
pitch | string | Pitch expression, evaluated at play time |
ducking | string | Channel or group to duck while this clip plays |
duck_level | float | Level to duck the ducked channel to (0.0–1.0) |
stack | bool | Allow overlapping instances of this clip instead of restarting it |
stack_limit | int | Maximum number of overlapping instances when stack is set |
Routing and selection
A device makes sound through two top-level, owner-addressed blocks — a route
that lists its candidate sounds and a selection that decides which one plays.
A nested audio {} block under a device no longer parses. For the full model
and a walkthrough, see Routing & Selection;
the block reference follows.
device "switch" "bumper" "pop_bumper_1" {
id = 12
}
route {
owners = device.pop_bumper_1
source "audio_clip" "bumper_normal" { weight = 3 }
source "audio_clip" "bumper_great" { weight = 1 }
mix { channel = "effects" }
}
selection {
owners = device.pop_bumper_1
selection_method = "weighted"
}route block
| Attribute / block | Type | Description |
|---|---|---|
owners | reference | Owner(s) this route serves — a single ref or a […] bank; one of device, score, modifier, accumulator, event_handler |
source "<kind>" "<name>" | block | A candidate. Kind is "audio_clip" or "synth" |
mix { channel } | block | Optional destination mixer channel |
Each source block:
| Attribute | Applies to | Description |
|---|---|---|
weight | any source | Relative routing weight; higher is chosen more often |
pitch | "synth" only | Voice frequency in Hz — number or expression |
velocity | "synth" only | Voice amplitude 0.0–1.0 — number or expression |
duration | "synth" only | Authored hold time, e.g. "0.5s" |
sustain | "synth" only | Hold the voice until the triggering switch opens; excludes duration |
position | "synth" only | Sub-block placing the voice in space: position { x = … y = … z = … } |
A routed "synth" source is the only way to fire a synth patch — see
Synth.
selection block
| Attribute | Type | Description |
|---|---|---|
owners | reference | Owner(s) this decision serves |
selection_method | string | random (default), weighted, rotate, shuffle, balanced, probability, coordinated |
no_repeat | int | Avoid replaying the last N picks |
shuffle_memory | int | For shuffle, how many recent picks the bag remembers |
clip_probability | expression | Live expression biasing the pick toward higher-weighted sources |
condition | expression | Gate — the owner stays silent unless this evaluates true |
Global audio configuration
A single top-level audio block (no label) configures the mixer for the whole
table:
audio {
master_volume = 0.9
device_audio = true
background_music {
clip = main_theme
auto_start = true
}
}| Property | Type | Default | Description |
|---|---|---|---|
master_volume | float | 0.9 | Master output level (0.0–1.0) |
device_audio | bool | true | Enable the device-driven audio system |
Background music
A background_music sub-block selects what plays as the table’s music. Use
clip for a single track or playlist to name a playlist declared in
a music block — the two are mutually exclusive:
| Property | Type | Description |
|---|---|---|
clip | string | Name of the audio_clip to play as music |
playlist | string | Name of a music playlist to play |
auto_start | bool | Start the music automatically |
envelope | string | Name of a top-level envelope block shaping fade-in/out |
condition | expression | Start/stop the music when this evaluates true, e.g. var.in_multiball |
Listener
A listener sub-block places the virtual microphone for spatial audio.
Positioned clips are mixed relative to it; omit it to keep everything on the
flat stereo mix.
audio {
listener {
x = 0.0
y = 0.0
z = 0.0
}
}Channels
A channels sub-block carries per-channel mixer settings. Each entry is a named
channel whose value is an object of settings:
audio {
channels {
effects = {
volume = 0.95
max_voices = 16
}
}
}Mixer channels and buses
For DAW-style routing, declare named mixer channel strips and buses at
the top level. A channel is a strip you route sound through; a bus is
structurally identical and is conventionally used as a shared effects
destination. Both carry an optional volume and pan plus an ordered chain of
insert effect blocks:
channel "voice" {
volume = 0.8
pan = 0.0
effect "reverb" {
feedback = 0.6
wet_level = 0.4
}
}
bus "reverb_bus" {
pan = -0.25
effect "compressor" {
threshold = -18
ratio = 4
}
}| Attribute | Type | Range | Description |
|---|---|---|---|
volume | float | 0–1 | Channel output level |
pan | float | -1–1 | Stereo position (-1 left, 1 right) |
Effects run in the order written. Each effect block takes a type label and
numeric parameters; an unknown type or parameter is a config error.
| Effect type | Parameters |
|---|---|
reverb | feedback, wet_level |
compressor | threshold, ratio, makeup_gain |
equalizer | low_gain, mid_gain, high_gain, low_freq, high_freq |
A top-level synth_effects "<name>" block declares the same kind of effect
chain as a reusable, named definition (its parameters may be expressions rather
than fixed numbers) for use with synth
voices.
Music
A top-level music block declares playlists and adaptive layers that a
background_music block can reference. A playlist groups tracks and picks
among them; a layer fades overlay stems in and out over a base track by
condition.
music {
playlist "gameplay" {
tracks = ["theme_a", "theme_b", "theme_c"]
mode = "shuffle"
crossfade = "2s"
track "theme_c" {
condition = var.wizard_mode
weight = 3
tags = ["wizard"]
}
}
layer "intensity" {
base = "gameplay_base"
overlay "drums" {
clip = "layer_drums"
condition = var.multiball_active
fade_in = "1s"
fade_out = "2s"
volume = 0.8
}
}
}Playlist attributes:
| Attribute | Type | Description |
|---|---|---|
tracks | list | Clip names in the playlist |
mode | string | Selection: random, weighted, rotate, shuffle, balanced, probability, coordinated |
shuffle_memory | int | How many recent tracks to avoid repeating |
track_probability | string | Name of a distribution variable driving track choice |
crossfade | duration | Overlap between tracks, e.g. "2s" |
gap | duration | Silence between tracks |
A nested track "<clip>" block refines a single track with a condition
(gate), a weight (for weighted modes), and tags.
Layer attributes: a layer "<name>" has a base clip and one or more
overlay "<name>" sub-blocks, each with clip, condition, fade_in,
fade_out, and volume.
Example
Declare a clip and trigger it from a rule:
audio_clip "target_hit" {
file = "sounds/target.wav"
preload = true
volume = 0.8
}
score "event" "standup_scored" {
when = device.standup.hit
points = 5000
audio {
clip = target_hit
}
}Synth
A synth block defines a procedural sound patch that generates audio at runtime from oscillators, envelopes, and filters — no recorded audio files required. Each synth compiles into a pre-allocated voice pool and is triggered by events.
Declaration Syntax
synth "<name>" {
oscillator "carrier" {
wave = "sine"
freq = 880
}
envelope "amp" {
attack = 2 # milliseconds
decay = 30
sustain = 0.0
release = 50
}
output {
in = node.carrier.out # the voice's signal
gain = node.amp.out # the amplitude VCA
}
}A synth requires at least one oscillator block and exactly one output block that wires the signal path explicitly. An amp envelope is optional: author one to shape the amplitude, or omit it for a minimal patch and cade supplies a default amplitude gate (near-instant attack, full sustain, short release) so a bare oscillator → output still sounds and releases cleanly. An unlabeled envelope is still treated as the "amp" envelope.
The smallest patch that makes a sound is one oscillator wired to the output — no envelope, no gain:
synth "blip" {
oscillator "carrier" {
wave = "sine"
freq = 660
}
output {
in = node.carrier.out
}
}Envelope times are plain numbers in milliseconds. Write
attack = 2, notattack = "2ms". Older string-with-unit values still load, but numbers are the current form.
Incomplete patches parse but don’t compile. A partly-wired synth (say a filter with no signal input, or an
outputmissing itsin) is accepted by the parser and stored — the editor lets you build up a patch step by step — and is rejected only at compile time, when the voice must make sound, with a specific reason. So a config that parses is not guaranteed to play until it compiles cleanly.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
polyphony | int | 64 | Number of simultaneous voices, range 1–128 |
oscillator Block
Each oscillator produces a tone. At least one is required; additional oscillators can act as modulators driving another oscillator’s frequency.
oscillator "carrier" {
wave = "saw"
freq = 440
amp = 1.0
}| Property | Type | Required | Default | Description |
|---|---|---|---|---|
| label | string | yes | — | Oscillator name; must be unique within the synth |
wave | string | yes | — | Wave shape: "sine", "saw", "square", "triangle" |
freq | float | yes | — | Base frequency in Hz |
amp | float | no | 1.0 | Amplitude; for a modulator oscillator, the FM depth in Hz |
envelope Block
Envelopes are named ADSR shapes. The "amp" envelope is the amplitude envelope; authoring one is optional (omit it and cade supplies a default amplitude gate — see above), but you can have at most one. Additional named envelopes can drive pitch or filter cutoff via a modulate block.
envelope "amp" {
attack = 5 # milliseconds
decay = 40
sustain = 0.3
release = 120
}
envelope "pitch" {
attack = 1
decay = 50
sustain = 0.0
release = 10
depth = 400
}| Property | Type | Required | Default | Description |
|---|---|---|---|---|
| label | string | no | "amp" | Envelope name; must be unique and not collide with an oscillator name |
attack | number | yes | — | Attack duration in milliseconds (e.g., 5) |
decay | number | yes | — | Decay duration in milliseconds |
sustain | float | yes | — | Sustain level, 0.0–1.0 |
release | number | yes | — | Release duration in milliseconds |
depth | float | no | 0 | Modulation range for non-amplitude envelopes, in Hz for pitch/cutoff |
The "amp" envelope ignores depth. For other envelopes the effective modulation at any instant is envelope_level × depth.
output Block
Every synth wires its signal path with exactly one output block. It names the node that produces the voice’s sound and, optionally, the envelope that acts as its amplitude VCA:
output {
in = node.carrier.out # or node.filter.out when a filter is in the path
gain = node.amp.out # optional — the "amp" envelope as the output VCA
}| Port | Wire | Required | Meaning |
|---|---|---|---|
in | node.<name>.out | yes | The node whose signal becomes the voice output |
gain | node.amp.out | no | The amplitude envelope driving the output VCA |
Wire gain when you author an amp envelope. Omit it for a minimal patch — cade’s default amplitude gate takes over so the voice still gates and releases.
Port-wires reference a node’s current-sample output as node.<name>.out, where <name> is an oscillator, envelope, mixer, or delay label (the filter node is always node.filter). A node.<name>.z1 port reads the node’s previous sample instead — used for feedback paths. Wiring a port to a node that was never declared is rejected at load, not silently ignored.
modulate Block
A modulate block routes a source (an oscillator or a named envelope) onto a target parameter. Use it for FM modulation between oscillators or to sweep a parameter with an envelope. Zero or more modulate blocks are allowed.
modulate {
source = "mod"
target = "carrier.freq"
}| Property | Type | Required | Description |
|---|---|---|---|
source | string | yes | Name of a defined oscillator or envelope |
target | string | yes | "<osc>.freq" (oscillator pitch) or "filter.cutoff" (envelope only) |
Targeting "filter.cutoff" requires a filter block, and the source must be a named envelope. The modulation graph between oscillators must be acyclic.
filter Block
An optional filter block adds a biquad filter to the voice. At most one filter block per synth. When a filter is in the signal path, wire its input with an in port-wire and route the voice output through it (output { in = node.filter.out … }):
filter {
type = "lowpass"
cutoff = 3000
resonance = 0.7
in = node.carrier.out
}| Property | Type | Required | Default | Description |
|---|---|---|---|---|
type | string | yes | — | "lowpass" or "highpass" |
cutoff | float | yes | — | Cutoff frequency in Hz; must be greater than 0 |
resonance | float | yes | — | Resonance, 0.0–1.0 |
in | wire | no | — | Signal input, node.<name>.out |
cutoff_mod | wire | no | — | Control-rate cutoff offset wire, added to the base cutoff |
To sweep the cutoff with an envelope, add an envelope and a modulate block targeting "filter.cutoff":
envelope "cutoff_env" {
attack = 1
decay = 60
sustain = 0.3
release = 100
depth = 2000
}
filter {
type = "lowpass"
cutoff = 3000
resonance = 0.7
in = node.carrier.out
}
modulate {
source = "cutoff_env"
target = "filter.cutoff"
}mixer Block
A mixer sums several signals into one node, so a patch can layer oscillators
or blend a dry signal with a processed one. Its inputs list holds port-wires;
an optional gains list weights them.
mixer "blend" {
inputs = [node.carrier.out, node.sub.out]
gains = [0.7, 0.3]
}| Property | Type | Required | Description |
|---|---|---|---|
| label | string | yes | Mixer name; addressable as node.<name>.out |
inputs | list | yes | Port-wires to sum — node.<name>.out, or node.<name>.z1 for a feedback tap |
gains | list | no | Per-input gains; must have exactly as many entries as inputs. Omit for an unweighted sum |
delay Block
A delay node feeds a signal back on itself after a fixed time — echo, slapback,
and comb effects.
delay "echo" {
in = node.carrier.out
time = 120 # milliseconds
feedback = 0.4
}| Property | Type | Required | Description |
|---|---|---|---|
| label | string | yes | Delay name; addressable as node.<name>.out |
in | wire | yes | Signal input, node.<name>.out |
time | number | yes | Delay time in milliseconds |
feedback | float | no | Portion of the output fed back in, 0.0–1.0 |
Triggering a Patch
A synth voice is fired through a top-level route block. The route names the
blocks that own the sound (owners) and lists the patch as a source "synth".
This is the only way to author a synth voice — every sound in the table,
recorded or generated, goes through the same routing path.
device "switch" "bumper" "pop_bumper_1" {
id = 32
}
event_handler "bumper_hit_sound" {
when = device.pop_bumper_1.hit
}
route {
owners = [event_handler.bumper_hit_sound]
source "synth" "bumper_hit" {
weight = 1
pitch = 880 # base frequency in Hz
velocity = 1.0 # 0.0–1.0
}
}The second label of the source block is the name of the synth patch to
play. Note that the handler above owns nothing but its route — a handler whose
only job is to make a sound needs no actions block at all.
owners accepts a single reference or a […] list, and each entry is one of
device, score, modifier, accumulator, or event_handler. Owning the
route from a device fires the sound directly off that device’s own events,
with no handler in between.
Synth source attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
weight | number | 0 | Relative routing weight when the route lists several sources |
pitch | expression | — | Voice frequency in Hz |
velocity | expression | 1.0 | Amplitude, 0.0–1.0 |
duration | string | — | Authored hold time (e.g. "0.5s"); releases the tail after it elapses |
sustain | bool | false | Hold the voice until the triggering switch’s open edge instead of a timer |
position | block | — | Spatial placement: position { x = … y = … z = … }, each axis defaulting to 0 |
pitch, velocity, duration, sustain, and position are valid only on a
"synth" source — setting them on an "audio_clip" source is an error.
sustain and duration are mutually exclusive: sustain has no authored
length, so asking for both is rejected at load.
By default a routed synth source is one-shot: the voice plays through attack → decay → release with no held note. Two attributes change that:
durationgives the note an authored hold time.duration = "0.5s"holds the voice for half a second before releasing the tail — good for chimes and pads.sustain = trueholds the voice open until the triggering switch releases, so the sound tracks how long the switch is actually held.
Add an optional mix { channel = "effects" } block to send the voice to a
specific mixer channel.
Value Bindings
pitch and velocity accept either a plain number or a bare expression
evaluated against live game state (score, var.<name>, signal.<name>,
event.<field>):
route {
owners = [score.jackpot]
source "synth" "chime" {
weight = 1
pitch = 440 + (score / 1000000) * 220 # rises with score
velocity = min(var.sfx_volume, 1.0)
}
}See Runtime Bindings for the full namespace and evaluation rules, and Routing & Selection for how a route picks between several candidate sources.
Effects
Reusable effect chains — reverb, compression, EQ — are declared once at the top
level with a synth_effects "<name>" block and applied through the mixer. Unlike
a mixer channel’s static chain, a synth_effects parameter may be an expression
that responds to live game state:
synth_effects "ball_fx" {
effect "reverb" {
feedback = var.tension * 0.5
wet_level = 0.3
}
effect "compressor" {
threshold = -20
ratio = 4
}
}The effect types and parameters are the same as a mixer channel’s — see Mixer channels and buses.
Examples
Simple Beep
synth "bumper_beep" {
oscillator "main" {
wave = "sine"
freq = 880
}
envelope "amp" {
attack = 2
decay = 30
sustain = 0.0
release = 50
}
output {
in = node.main.out
gain = node.amp.out
}
}FM Bell Tone
synth "metallic_hit" {
oscillator "carrier" {
wave = "sine"
freq = 660
}
oscillator "mod" {
wave = "sine"
freq = 80
amp = 200 # ±200 Hz modulation depth
}
modulate {
source = "mod"
target = "carrier.freq"
}
envelope "amp" {
attack = 1
decay = 60
sustain = 0.1
release = 150
}
output {
in = node.carrier.out
gain = node.amp.out
}
polyphony = 32
}Filtered Saw with Envelope Sweep
synth "bumper_zap" {
oscillator "carrier" {
wave = "saw"
freq = 880
}
envelope "amp" {
attack = 2
decay = 30
sustain = 0.2
release = 80
}
envelope "pitch" {
attack = 1
decay = 50
sustain = 0.0
release = 10
depth = 400
}
envelope "cutoff_env" {
attack = 1
decay = 60
sustain = 0.3
release = 100
depth = 2000
}
filter {
type = "lowpass"
cutoff = 3000
resonance = 0.7
in = node.carrier.out
}
modulate {
source = "pitch"
target = "carrier.freq"
}
modulate {
source = "cutoff_env"
target = "filter.cutoff"
}
output {
in = node.filter.out
gain = node.amp.out
}
polyphony = 64
}Envelope
A top-level envelope block declares a reusable ADSR shape at config scope —
an attack/decay/sustain/release contour that other parts of the table can
reference by name. It is the same envelope shape used inside a
synth patch, but declared once at the top
level so it can be shared rather than repeated.
The most common use is shaping background music: a
background_music block names an envelope
to fade a track in and out instead of cutting it hard.
Block declaration
envelope "music_fade" {
attack = 800 # milliseconds
decay = 0
sustain = 1.0
release = 1200
}The block takes a single label — the envelope name — used to reference it
elsewhere. If the label is omitted it defaults to "amp".
| Attribute | Type | Required | Default | Description |
|---|---|---|---|---|
attack | number | Yes | — | Time to rise from silence to full level, in milliseconds |
decay | number | Yes | — | Time to fall from full level to the sustain level, in milliseconds |
sustain | number | Yes | — | Held level after decay, 0.0–1.0 |
release | number | Yes | — | Time to fall from the sustain level back to silence, in milliseconds |
depth | number | No | 0 | Modulation range for non-amplitude uses (Hz for pitch/filter). Ignored when the envelope shapes level/volume |
Envelope times are plain numbers in milliseconds. Write
attack = 5, notattack = "5ms"— the string-with-unit form is not a valid envelope time.
attack, decay, sustain, and release are all required; leaving any of them
out is a configuration error.
Using an envelope for music
Reference the envelope by name from a background_music block so the track fades
on start and stop rather than beginning and ending abruptly:
envelope "music_fade" {
attack = 1000
decay = 0
sustain = 1.0
release = 2000
}
audio {
background_music {
playlist = "gameplay"
envelope = "music_fade"
auto_start = true
}
}Here the music ramps up over one second when it starts and fades out over two
seconds when it stops. See the
Audio page for the full
background_music block.
Relationship to synth envelopes
A synth patch declares its own envelope
blocks inside the synth block — an "amp" envelope shapes the voice’s
amplitude (optional; omit it and cade supplies a default amplitude gate), and
named envelopes can drive pitch or filter cutoff through a modulate block. An
unlabeled envelope inside a synth is treated as the "amp" envelope. Those
inline envelopes and this top-level block share the exact same
attributes; the only difference is scope. Declare an envelope at the top level
when more than one place needs the same shape, or to shape background music.
Light Layout
A light_layout block gives your lights a position in space. On their own,
light devices are just addressable channels; a layout places each one on a
normalized 2-D field (x and y from 0.0 to 1.0) so a
lightshow can render a pattern —
a gradient, a swirl, a wipe — across them instead of addressing each light by
hand.
A layout is declared once at the top level and referred to by name from a
lightshow’s output block (target = layout.<name>). A table can declare as
many layouts as it needs — one for the playfield inserts, one for a backbox
strip, and so on.
Block declaration
light_layout "strip" {
place "l0" { at = [0.0, 0.0] }
place "l1" { at = [0.5, 0.0] }
place "l2" { at = [1.0, 0.0] }
}The block takes a single label — the layout name — used to reference it from a
lightshow. Positions are given in normalized layout space: [0.0, 0.0] is one
corner, [1.0, 1.0] the opposite one. A layout defines its positions three
ways, which can be combined in one block: a regular matrix grid, individual
place bindings, and named region groups.
Placing lights individually
A place block positions one light device at a point. Its label is the device
reference, and at is the required [x, y] coordinate:
place "left_ramp_insert" { at = [0.15, 0.60] }| Attribute | Type | Required | Description |
|---|---|---|---|
at | [x, y] number | Yes | Normalized position of this light in the layout (0.0–1.0 each) |
The label is the light device being placed — the same name you declared it with,
for example place "left_ramp_insert". Every placement must resolve to a
declared device "light" ..., and both coordinates must fall within 0.0–1.0;
anything else is a configuration error.
A label may be dotted, in which case only the final segment names the device.
That is how you place a light an assembly
generated: an instance’s blocks are named <instance>__<block>, so a use "insert_bank" "left"
whose assembly declares a light lamp is placed as place "left__lamp".
Laying out a grid
A matrix block lays down a regular grid of virtual sample points in one
step — useful as the raster a show renders across an LED panel or a rectangular
insert bank. Each cell contributes one point at its centre; the cells are not
themselves bound to devices, so a layout that needs to drive real lights still
places them. A layout may declare at most one matrix. rows and cols are
required; origin and size place and scale the grid within the layout’s
normalized space:
light_layout "panel" {
matrix {
rows = 8
cols = 8
origin = [0.10, 0.10] # top-left corner of the grid
size = [0.80, 0.80] # width and height it spans
}
}| Attribute | Type | Default | Description |
|---|---|---|---|
rows | integer | — | Number of grid rows (required) |
cols | integer | — | Number of grid columns (required) |
origin | [x, y] number | [0, 0] | Position of the grid’s first cell |
size | [w, h] number | [1, 1] | Fraction of the layout the grid spans |
rows and cols must be positive, and origin plus size must stay inside the
normalized 0.0–1.0 field — a grid that runs off the edge is rejected.
Grouping lights into regions
A region block gives a name to a set of placed lights so a lightshow can treat
them as a unit. Each region contains its own place blocks:
light_layout "playfield" {
region "left_orbit" {
place "orbit_insert_1" { at = [0.10, 0.30] }
place "orbit_insert_2" { at = [0.10, 0.55] }
}
region "right_ramp" {
place "ramp_insert_1" { at = [0.90, 0.30] }
place "ramp_insert_2" { at = [0.90, 0.55] }
}
}| Element | Description |
|---|---|
| label | The region name |
place | One or more placements, same form as a top-level place |
Editor metadata
A mesh_hint block may carry renderer or editor hints (surface shape, preview
mesh, and similar). It is passed through untouched and ignored by the runtime, so
it never affects playback — you can safely omit it in hand-written tables.
Example
Three RGB lights placed along a strip, ready for a lightshow to sweep across:
device "light" "rgb" "l0" { id = 20 }
device "light" "rgb" "l1" { id = 21 }
device "light" "rgb" "l2" { id = 22 }
light_layout "strip" {
place "l0" { at = [0.0, 0.0] }
place "l1" { at = [0.5, 0.0] }
place "l2" { at = [1.0, 0.0] }
}A lightshow then targets this layout by
name with output { target = layout.strip }.
Lightshow
A lightshow block is a generative light program: a small graph of nodes —
sources, transforms, and composites — that produces color across a
light layout frame by frame. Instead
of scripting each light, you describe how the color is generated (a drifting
noise field, a gradient sweep, a logo image) and Cade renders it deterministically
across the lights the layout places in space.
Shows run on top of your normal lighting: a lightshow composites into a priority
stack, so per-event overrides (a flashing insert on a hit) still paint over the
show. Playback is deterministic — the same seed produces the same frames — so
replays and tests look identical.
Block declaration
lightshow "attract_swirl" {
mode = "ambient"
seed = 42
node "noise" "field" {
scale = 0.3
speed = 1.0
}
node "colorize" "pal" {
in = node.field.out
}
output {
in = node.pal.out
target = layout.playfield
}
}The block takes a single label — the show name. The body holds the show’s
timing attributes, an optional bake block, one or more node blocks wired
together, and one or more output blocks that composite the result onto a
layout.
| Attribute | Type | Default | Description |
|---|---|---|---|
mode | string | — | "clip" (fixed-length) or "ambient" (runs continuously). Required |
duration | integer | — | Clip length in milliseconds. Required when mode = "clip" |
loop | bool | — | Whether a clip repeats. Clip-only — invalid with mode = "ambient" |
seed | integer | hash of show name | Seed for deterministic noise and random sources |
Modes
A show is either a clip or ambient:
clip— a fixed-length animation. It requires aduration(in ms) and may setloopto repeat. Use it for a scored celebration, a mode intro, or a jackpot flourish that plays once and ends.ambient— runs continuously with no fixed length, soloopdoes not apply. Use it for attract-mode backgrounds and idle playfield ambiance.
Bake controls
An optional bake block bounds how source nodes are sampled into lookup tables
before they are mapped onto the layout. Both attributes are optional; leave the
block out to use engine defaults.
bake {
fps = 60 # source sampling rate
resolution = [64, 64] # raster resolution before mapping onto layout coords
}| Attribute | Type | Description |
|---|---|---|
fps | integer | Sampling rate of source nodes into LUTs |
resolution | [w, h] integer | Raster resolution before mapping onto the layout |
Nodes
Each node block is one operation in the show’s graph. It takes two labels —
the node kind (from the catalog below) and a name unique within the show:
node "gradient" "sky" {
kind = "linear"
angle = 90
}Node names must be unique within a show, and the kind must exist in the catalog (an unknown kind is a configuration error). A node’s attributes are its parameters, and each takes one of three forms:
| Parameter form | Example | Meaning |
|---|---|---|
| Literal | angle = 90 | A constant, folded at compile time |
| Expression | edge = var.intensity * 0.5 | A live value re-evaluated each tick (references var./event fields) |
| Port wire | in = node.field.out | A connection from another node’s output port |
Wiring nodes together
Connect nodes by setting an input parameter to another node’s output port, written
bare as node.<name>.<port>:
node "noise" "field" { scale = 0.3 }
node "colorize" "pal" { in = node.field.out } # field.out → pal.in
node "blend" "mix" {
a = node.pal.out
b = node.logo.out
mode = "screen"
}Most inputs are named in; nodes that combine fields use named ports such as
a/b (blend), in/mask (mask), or in/displace (warp). The graph must be
acyclic, except for the feedback node, which is the one kind allowed to
reference the previous frame.
The layer node’s in port is variadic — wire any number of fields into it
by writing a list of port wires, and they stack under the node’s shared blend
mode:
node "layer" "stack" {
in = [node.base.out, node.logo.out, node.sparkle.out]
mode = "screen"
}Node catalog
Nodes are grouped by role. Sources generate a field; transforms reshape one; composites combine two or more; feedback nodes carry state between frames.
| Kind | Group | Produces / does |
|---|---|---|
solid | source | A single constant color across the field |
gradient | source | A linear or radial gradient (kind, angle, center, palette) |
noise | source | A coherent noise field (type, scale, speed, octaves, seed) |
image | source | A static image sampled onto the layout (src, fit) |
sprite | source | An animated gif / sprite sheet (src, fps, frames, loop) |
video | source | A video decoded to frames at bake (src, fps, loop) |
lfo | source | A low-frequency oscillator control value (shape, hz, phase) |
time | source | Show time as a control value (mode = seconds or clip-progress) |
var | input | A game-state variable surfaced as a control value (ref) |
colorize | transform | Map a scalar field through a palette to color (palette, mode) |
levels | transform | Remap a scalar field’s range with gamma |
threshold | transform | Threshold a scalar field with a soft edge (edge, softness) |
gain | transform | Adjust brightness / contrast / saturation |
hue_shift | transform | Rotate the hue of a color field (degrees) |
warp | transform | Displace a color field by a scalar field (amount, axis) |
blur | transform | Box / Gaussian blur (radius) |
remap | transform | Scroll / rotate / scale / tile coordinates |
mask | transform | Multiply a color field by a scalar mask field |
blend | composite | Blend two color fields (mode, factor) |
layer | composite | Stack any number of color fields with a shared blend mode |
feedback | feedback | Blend the previous frame back in for trails / decay (decay) |
decay | feedback | A release envelope over a scalar field (release_ms) |
output | output | Emit a color field to a layout (see below) |
Output
An output block composites a node’s color onto a layout as one layer of the
priority stack. A show can have several outputs — for example one per layout, or
several priorities onto the same layout.
output {
in = node.mix.out
target = layout.playfield
priority = 10
blend = "screen"
opacity = 0.8
}| Attribute | Type | Default | Description |
|---|---|---|---|
in | port | — | The node output to emit, e.g. node.mix.out. Required |
target | layout | — | The layout to render onto, e.g. layout.playfield |
priority | integer | 0 | Compositor priority; higher layers paint over lower ones |
blend | string | "over" | Blend mode against layers below (see below) |
opacity | float | 1.0 | Layer opacity (0.0–1.0) |
Blend modes
blend (on output, blend, and layer nodes) accepts:
| Mode | Effect |
|---|---|
over | Normal alpha compositing (default) |
add | Additive — brightens |
multiply | Multiplicative — darkens |
screen | Inverse-multiply — brightens, softer than add |
max | Per-channel maximum |
mix | Linear interpolation by factor |
Editor metadata
An editor block stores canvas positions for the visual show editor:
editor {
node "field" { at = [40, 40] }
node "pal" { at = [220, 40] }
}It is captured verbatim and ignored by the runtime, so it never affects playback. Hand-written tables can leave it out entirely.
Example
A complete ambient show: a solid red source composited onto a strip layout.
device "light" "rgb" "l0" { id = 20 }
device "light" "rgb" "l1" { id = 21 }
device "light" "rgb" "l2" { id = 22 }
light_layout "strip" {
place "l0" { at = [0.0, 0.0] }
place "l1" { at = [0.5, 0.0] }
place "l2" { at = [1.0, 0.0] }
}
lightshow "preview" {
mode = "ambient"
node "solid" "base" {
color = "#ff0000"
}
output {
in = node.base.out
target = layout.strip
blend = "over"
}
}See Light Layout for placing the lights a show renders onto.
Device
Device blocks declare and configure physical hardware in a pinball machine. They centralize hardware-specific settings such as IDs, debounce times, electrical characteristics, and behavioral properties, separating hardware configuration from game logic.
Syntax
A device block takes three labels: category, type, and name. The category determines the hardware class, type specifies the behavior variant, and name provides a unique identifier used in event generation and references.
device "switch" "bumper" "pop_bumper_1" {
id = 0x40
tags = ["pop_bumper", "playfield"]
description = "Upper left pop bumper"
debounce {
activate_ms = 2
release_ms = 2
}
}Device Categories
| Category | Description | Example Types |
|---|---|---|
switch | All switch types | slingshot, opto, spinner, target, drop, standup, rollover, bumper, button, gate, trough, mechanical |
coil | All coil/solenoid types | standard, flipper, diverter, motor, bumper |
flipper | Flipper assemblies declared as one unit | standard |
light | All light types | led, rgb, gi_string, flasher |
display | All display types | dmd, lcd, segment |
motor | Stepper and servo motors | stepper, servo |
sensor | Advanced sensors | accelerometer, ir |
Switch subtypes target, drop, and standup are all distinct. Use target for generic hit-only targets that do not need the drop/standup semantics below; use drop for targets that fall and must be reset with a coil; use standup for upright hit-only targets that are conventionally grouped into banks.
Common Properties
These properties are available on all device blocks:
| Property | Type | Default | Description |
|---|---|---|---|
id | hex/int | required | Hardware identifier |
tags | list | [] | Grouping tags for bulk references |
description | string | "" | Human-readable description |
part_number | string | "" | Replacement part number |
notes | string | "" | Service notes |
enabled | bool | true | When false, the device is declared but ignored by the runtime — inputs are dropped and action verbs become no-ops |
Debounce Settings
Switch devices support debounce configuration to filter electrical noise:
debounce {
activate_ms = 2 # Minimum time before registering activation
release_ms = 2 # Minimum time before registering release
type = "active_low" # active_low, active_high, both_edges
sample_count = 3 # Samples required for state change
}| Property | Type | Default | Description |
|---|---|---|---|
activate_ms | int | 2 | Milliseconds stable before activation |
release_ms | int | 2 | Milliseconds stable before release |
type | string | "active_low" | Edge detection type |
sample_count | int | 3 | Required consecutive samples |
Automatic Event Generation
Device blocks automatically generate events based on the device type. Events follow the pattern device.<name>.<action>:
| Category | Type | Generated Events |
|---|---|---|
| switch | spinner | device.<name>.spin |
| switch | target | device.<name>.hit |
| switch | rollover | device.<name>.rollover |
| switch | loop | device.<name>.cleared |
| switch | ramp | device.<name>.cleared |
| switch | orbit | device.<name>.cleared |
| switch | slingshot | device.<name>.hit |
| switch | bumper | device.<name>.hit |
| switch | button | device.<name>.hit |
| switch | opto | device.<name>.cleared |
| switch | gate | device.<name>.cleared |
| switch | trough | device.<name>.active, device.<name>.cleared |
| switch | (any other) | device.<name>.activated |
Coils, lights, and flippers generate no action event of their own — they are commanded, not read.
All switch devices also generate device.<name>.active and device.<name>.inactive events for raw state changes.
Event Aliases
Event aliases give mechanical devices the vocabulary their behavior calls for, without multiplying low-level names. Aliases are emitted on top of the raw active / inactive events — handlers can subscribe to whichever name reads most naturally.
| Alias | Emitted by | Meaning |
|---|---|---|
rested | switch drop | Target has settled in the up position after a reset pulse completes |
state_changed | any device with explicit state | The device’s declared state has transitioned (e.g., drop-target FSM) |
moved | motor stepper, motor servo | The device has begun moving toward a new target position |
reached | motor stepper, motor servo | The device has arrived at its commanded position |
moving | motor stepper, motor servo | The device is currently in motion (emitted continuously per tick) |
Aliases carry the same event payload as the underlying hardware event, plus a state field whose value matches the device’s declared state or position name.
Drop Targets
A switch drop declares a drop target that falls when hit and must be reset with a coil. Drop targets implement an implicit state machine — up → hit → resetting → up — and emit both the per-state aliases above and rested once the reset pulse completes.
device "switch" "drop" "drop_center" {
id = 0x51
tags = ["drop_bank", "center"]
reset {
coil = device.drop_reset_coil
pulse_ms = 30
debounce_ms = 50
}
}| Sub-block | Field | Type | Default | Description |
|---|---|---|---|---|
reset | coil | device | required | Coil device that physically resets the target |
reset | pulse_ms | int | 30 | Coil pulse duration when firing the reset |
reset | debounce_ms | int | 50 | Quiet window after reset before the target is considered rested |
Events emitted: device.<name>.hit, device.<name>.state_changed, device.<name>.rested, plus the raw active/inactive.
Stand-up Targets
A switch standup declares an upright hit-only target. Unlike drop targets, stand-ups have no reset semantics — they stay upright after being struck.
device "switch" "standup" "standup_1" {
id = 0x61
tags = ["standup_bank"]
}Events emitted: device.<name>.hit plus the raw active/inactive. There is no rested or reset {} sub-block.
Stand-ups are conventionally grouped with a shared tag so a single handler can score all targets in the bank:
score "event" "standup_hit" {
when = tag.standup_bank.hit
points = 500
}Servo Positions
Servo motors (motor servo) support a positions {} block that assigns symbolic names to target positions. This keeps game logic driver-agnostic — the .cade file refers to named positions, and the platform driver maps those to the physical pulse width or step count.
device "motor" "servo" "left_diverter" {
id = 0x80
tags = ["diverter"]
positions {
home = 0
up = 90
down = -45
}
}| Field | Type | Description |
|---|---|---|
home | number | Conventional rest/parked position (not required, but widely used as the default start) |
| any | number | Additional named positions (driver-agnostic scalar values) |
Events emitted: device.<name>.moved, device.<name>.moving, device.<name>.reached, and device.<name>.state_changed. The state field on each event carries the position name.
Behavior
The behavior {} sub-block declares hardware-level reflexes the device should perform without round-tripping through the scoring engine. Behaviors are the declarative analog of a light show: you describe the input, the action, and any guards, and the config compiler lowers them to autofire-style rules the platform driver can run locally.
The block takes no label; on and do are both required. do names an action
verb in call form, where self refers to the device the behavior is attached to.
device "switch" "bumper" "pop_bumper_1" {
id = 0x40
behavior {
on = "device.pop_bumper_1.active"
do = "pulse_coil(self)"
}
}| Property | Type | Required | Description |
|---|---|---|---|
on | string | Yes | Event reference or runtime expression that triggers the action |
do | string | Yes | Action verb in call form, e.g. pulse_coil(self) or move_servo(self, position="up") |
Behaviors are compiled at config-load time and never evaluated per-tick in the scoring engine. Because of this, they preserve the standard performance budgets (50–100 Hz event tick, <200 ns runtime binding resolve, <1 ms scoring) and remain responsive even when the scoring engine is busy.
Action Verbs
Action verbs are declarative commands used by both behavior {} blocks and event handlers. They describe what to do in driver-agnostic terms — the platform driver chooses the physical implementation.
| Verb | Targets | Purpose |
|---|---|---|
pulse_coil | coil device | Fire a coil for a bounded duration |
move_servo | motor servo device | Command a servo to a named positions {} entry |
set_state | any stateful device | Transition a device’s declared state (e.g., drop-target FSM) |
trigger_behavior | any device with behavior {} named blocks | Fire a named behavior on demand |
pulse_coil
pulse_coil "drop_reset_coil" {
duration = "30ms"
}The coil comes from the block label. duration is a duration string; power
(an integer) is also accepted.
move_servo
move_servo {
device = device.left_diverter
position = "up"
}The position value must be a key declared in the target servo’s positions {}
block. Alternatively, give angle an integer to command a raw position with no
positions {} entry:
move_servo {
device = device.left_diverter
angle = 45
}Exactly one of position or angle is required — supplying both, or neither,
is a config error.
set_state
set_state {
device = device.drop_center
position = "up"
}trigger_behavior
trigger_behavior {
device = device.pop_bumper_1
behavior = "auto_fire_coil"
}Device References
Devices are referenced using the device.<name> syntax in scoring rules, event handlers, and other configuration:
score "event" "spinner_points" {
when = device.center_spinner.spin
points = 100
}Tag-based references match all devices with a given tag:
event "switch" {
device = tag.slingshot
}Device Settings
Certain device types support a settings block for physics-abstract parameters. These values are driver-independent — each platform driver interprets them according to its own physics model.
Flipper Settings
device "flipper" "standard" "left_flipper" {
id = 1
hardware {
coil = "C01"
switch = "S01"
}
settings {
strength = 75
hold_time = "250ms"
}
}| Setting | Type | Description |
|---|---|---|
strength | int | Flipper kick strength (physics-abstract) |
hold_time | duration | How long the flipper holds in the up position |
Kicker Settings
Kicker devices support kick physics parameters in their settings block. These values are pushed to the platform driver during registration and consulted when a kick_ball action references the kicker by name.
device "switch" "kicker" "Kicker1" {
id = 41
settings {
kick_angle = 190
kick_strength = 10
}
}| Setting | Type | Description |
|---|---|---|
kick_angle | int | Kick angle in degrees (physics-abstract) |
kick_strength | int | Kick strength (physics-abstract, 0 = device default) |
The VPX driver maps these to Kicker::Kick(angle, speed, inclination) parameters. A physical machine driver would map them to coil pulse parameters. The kick_ball action block itself carries only the device name, keeping game logic driver-agnostic.
Device Audio
A device makes sound through two top-level blocks that name it as their
owner: a route{} listing the candidate sounds, and a selection{} deciding
which one plays. A nested audio {} block under a device no longer parses —
it is a hard error, not a silent no-op.
device "switch" "bumper" "pop_bumper_1" {
id = 12
}
route {
owners = device.pop_bumper_1
source "audio_clip" "bumper_normal" { weight = 3 }
source "audio_clip" "bumper_good" { weight = 2 }
source "audio_clip" "bumper_great" { weight = 1 }
}
selection {
owners = device.pop_bumper_1
selection_method = "weighted"
}A single-clip device needs only the route; selection defaults to random,
which is indistinguishable from fixed when there is one candidate. A source
may also be a synth voice rather than a recorded clip:
route {
owners = device.pop_bumper_1
source "synth" "chime" {
weight = 1
pitch = 880
velocity = 1.0
}
}See Routing and selection for the full attribute list.
Examples
Pop Bumper Assembly
device "switch" "bumper" "pop_bumper_1" {
id = 0x40
debounce {
activate_ms = 2
}
tags = ["pop_bumper", "playfield"]
}
device "coil" "bumper" "pop_bumper_1_coil" {
id = 0x30
pulse_ms = 20
recycle_ms = 100
tags = ["pop_bumper"]
}
device "light" "led" "pop_bumper_1_light" {
id = 0x90
color = "red"
tags = ["pop_bumper"]
}Flipper Assembly
device "switch" "button" "left_flipper_button" {
id = 0x01
debounce {
activate_ms = 0
release_ms = 0
}
tags = ["flipper", "cabinet"]
}
device "coil" "flipper" "left_flipper_main" {
id = 0x00
pulse_ms = 30
eos_switch = device.left_flipper_eos
tags = ["flipper"]
}
device "switch" "eos" "left_flipper_eos" {
id = 0x02
normally_closed = true
tags = ["flipper", "eos"]
}Opto Ball Trough
device "switch" "opto" "trough_1" {
id = 0x60
invert = true
debounce {
activate_ms = 20
release_ms = 20
}
tags = ["trough", "ball_detection"]
}
device "switch" "opto" "trough_2" {
id = 0x61
invert = true
debounce {
activate_ms = 20
release_ms = 20
}
tags = ["trough", "ball_detection"]
}
device "coil" "standard" "trough_eject" {
id = 0x40
pulse_ms = 25
recycle_ms = 500
tags = ["trough"]
}Drop Target Bank
device "coil" "standard" "drop_reset" {
id = 0x32
pulse_ms = 30
recycle_ms = 250
tags = ["drop_bank"]
}
device "switch" "drop" "drop_left" {
id = 0x50
tags = ["drop_bank"]
reset {
coil = device.drop_reset
pulse_ms = 30
debounce_ms = 50
}
}
device "switch" "drop" "drop_center" {
id = 0x51
tags = ["drop_bank"]
reset {
coil = device.drop_reset
pulse_ms = 30
debounce_ms = 50
}
}
device "switch" "drop" "drop_right" {
id = 0x52
tags = ["drop_bank"]
reset {
coil = device.drop_reset
pulse_ms = 30
debounce_ms = 50
}
}
score "event" "drop_hit" {
when = tag.drop_bank.hit
points = 1000
}
score "event" "bank_cleared_bonus" {
when = tag.drop_bank.rested
points = 5000
}Stand-up Target Array
device "switch" "standup" "standup_1" {
id = 0x61
tags = ["standup_bank"]
}
device "switch" "standup" "standup_2" {
id = 0x62
tags = ["standup_bank"]
}
device "switch" "standup" "standup_3" {
id = 0x63
tags = ["standup_bank"]
}
device "switch" "standup" "standup_4" {
id = 0x64
tags = ["standup_bank"]
}
device "switch" "standup" "standup_5" {
id = 0x65
tags = ["standup_bank"]
}
score "event" "standup_award" {
when = tag.standup_bank.hit
points = 500
}Servo-Driven Diverter
device "motor" "servo" "left_diverter" {
id = 0x80
tags = ["diverter"]
positions {
home = 0
up = 90
down = -45
}
}
event_handler "arm_diverter" {
when = signal.ramp_armed
actions {
move_servo {
device = device.left_diverter
position = "up"
}
}
}
event_handler "park_diverter" {
when = device.left_diverter.reached
condition = event.state == "up"
actions {
move_servo {
device = device.left_diverter
position = "home"
}
}
}Platform
Platform blocks configure hardware drivers that bridge Cade to physical or virtual pinball hardware. Each platform driver is declared with a type and an instance name. Multiple instances of the same driver type can coexist with different configurations.
Platform blocks live in your cade.conf application config — not in .cade table files. Cade looks for cade.conf in the current directory, then $XDG_CONFIG_HOME/cade/cade.conf (usually ~/.config/cade/cade.conf); override the location with --config.
Syntax
A platform block takes two labels: the driver type and an instance name. Cade includes three built-in driver types — fast (FAST Pinball serial hardware), grpc (network bridge for external systems like Visual Pinball), and virtual (software simulation for development and testing). Any other type label fails at startup.
platform "fast" "main" {
net_port = "/dev/ttyUSB0"
baud = 921600
watchdog_ms = 1000
}Instance names must be unique across all platform blocks, regardless of driver type — platform "virtual" "dev" and platform "fast" "dev" collide.
Attribute names are not checked when the file is read: a misspelled driver attribute is silently ignored rather than reported, and surfaces later as a missing-value error (or as the default quietly taking effect). Values must be strings, numbers, or booleans — a list-valued attribute anywhere in a platform block aborts config loading.
Common Properties
These apply to every driver type:
| Property | Type | Default | Description |
|---|---|---|---|
connect_timeout | string | "10s" | Duration to wait for the initial connection |
command_timeout | string | "100ms" | Duration to wait for a command to be acknowledged |
reconnect_interval | string | "5s" | Duration between reconnection attempts |
debug_logging | bool | false | Log driver traffic |
debug_log | string | "" | Path to write the driver debug log |
The three duration values are duration strings ("10s", "250ms", "1m"); an unparseable value is a config error.
FAST Pinball
The FAST driver communicates with FAST Pinball controller boards over serial ports.
platform "fast" "main" {
net_port = "/dev/ttyUSB0"
exp_port = "/dev/ttyUSB1"
baud = 921600
platform = "2000"
watchdog_ms = 1000
}Properties
| Property | Type | Default | Required | Description |
|---|---|---|---|---|
net_port | string | (none) | yes | Serial port for the NET processor (the only required attribute in a platform block) |
exp_port | string | "" | no | Serial port for the EXP processor |
baud | int | 921600 | no | Serial baud rate (must be positive) |
platform | string | "2000" | no | FAST platform code ("2000" for Neuron) |
watchdog_ms | int | 1000 | no | Watchdog timeout in milliseconds (non-negative) |
gRPC
The gRPC driver exposes a gRPC server that external systems (such as Visual Pinball X) connect to for bidirectional communication.
platform "grpc" "vpx_bridge" {
port = 50051
enable_gateway = true
enable_reflection = true
enable_tls = false
enable_cors = true
}Properties
| Property | Type | Default | Required | Description |
|---|---|---|---|---|
port | int | 50051 | no | gRPC server listen port (1-65535) |
platform_address | string | "" | no | Address of an external platform service to connect out to (e.g. "localhost:50052"); empty leaves the driver in server-only mode |
connect_timeout_sec | int | 10 | no | Timeout in seconds for the initial connection to platform_address (must be at least 1) |
enable_gateway | bool | true | no | Enable the gRPC-gateway REST proxy |
enable_reflection | bool | true | no | Enable gRPC server reflection |
enable_tls | bool | false | no | Enable TLS encryption |
cert_file | string | "" | no | Path to the TLS certificate file |
key_file | string | "" | no | Path to the TLS private key file |
enable_cors | bool | true | no | Enable CORS headers on the gateway |
When enable_cors is enabled the gateway permits all origins; the allowed origin list is not configurable.
Setting enable_tls = true without both cert_file and key_file is not caught when the config is read — the driver fails when it starts. Always set all three together.
Virtual
The Virtual driver simulates pinball hardware in software. It is used for development, testing, and running table configurations without physical hardware.
platform "virtual" "dev" {
switch_count = 64
coil_count = 32
light_count = 128
servo_count = 8
}Properties
| Property | Type | Default | Required | Description |
|---|---|---|---|---|
switch_count | int | 64 | no | Number of virtual switches |
coil_count | int | 32 | no | Number of virtual coils |
light_count | int | 128 | no | Number of virtual lights |
servo_count | int | 8 | no | Number of virtual servos |
All count values must be non-negative.
Examples
Visual Pinball Development Setup
platform "grpc" "vpx_bridge" {
port = 50051
enable_reflection = true
enable_gateway = true
enable_cors = true
}FAST Hardware with Dual Processors
platform "fast" "main" {
net_port = "/dev/ttyUSB0"
exp_port = "/dev/ttyUSB1"
baud = 921600
platform = "2000"
watchdog_ms = 1000
}Headless Testing
platform "virtual" "test" {
switch_count = 128
coil_count = 64
light_count = 256
servo_count = 16
}Noise Context
A noise_context block declares a named, seedable source of parametric noise — an algorithm, a seed, and a cache size, recorded under a name.
Note: named contexts are declared and validated today, but expressions cannot yet select one. The noise functions (noise, noise_range, perlin, simplex, hash_noise) all draw from the table’s single built-in noise stream, seeded from the game. Declare contexts to describe the streams your table wants; the per-context binding is not wired up yet.
Declaration Syntax
noise_context "<name>" {
algorithm = "simplex" # "perlin", "simplex", "hash", "white"
seed_base = 12345 # integer seed, or "time" / "random" / "deterministic"
quality = "balanced" # advisory hint
cache_size = 256 # number of recent noise values to cache
}A noise_context block takes exactly one label — the context name. Every attribute is optional, and no other attributes are accepted.
| Attribute | Type | Description |
|---|---|---|
| name | string | Context name (the block label) |
algorithm | string | Noise algorithm: "perlin", "simplex", "hash", or "white" |
seed_base | integer/string | Fixed integer seed, or "deterministic", "time", or "random" |
quality | string | Advisory quality hint: "fast", "balanced", "high_quality" |
cache_size | integer | Number of recent noise values to cache (defaults to 256) |
Only the four algorithm names above are accepted — any other value is a configuration error. quality, by contrast, is not validated and does not change output: each algorithm’s own characteristics determine the quality, so treat quality as documentation of intent. seed_base is not validated either, so check the spelling of "deterministic", "time", and "random" yourself.
Algorithms
Four algorithms are available, trading coherence against speed:
| Algorithm | Character | Best for |
|---|---|---|
perlin | Smooth, coherent values with natural variation | High-quality smooth transitions |
simplex | Coherent like Perlin with better performance | Balanced general-purpose noise |
hash | Good distribution, less coherence, very fast | High-frequency sampling (e.g. probability) |
white | Pure uncorrelated randomness | random()-style draws with no smoothing |
Seed Base
Set seed_base to a fixed integer to pin a context to a reproducible sequence — the same seed always produces the same values. Use "deterministic" for a fixed zero seed, or "time" / "random" when you want fresh randomness on every run. Leaving seed_base unset behaves the same as "random".
# Deterministic context — identical results across runs
noise_context "scoring" {
algorithm = "simplex"
seed_base = 12345
quality = "balanced"
}
# Time-seeded context — true randomness each run
noise_context "expression_random" {
algorithm = "white"
seed_base = "time"
quality = "fast"
}Multiple Contexts
Declare a context per system — scoring variation, probability sampling, animation timing — so each one’s intended algorithm and seed are recorded in one place. Give each its own seed_base so the streams will stay distinct once per-context selection lands.
noise_context "scoring" {
algorithm = "simplex"
seed_base = 12345
quality = "balanced"
}
noise_context "probability_variables" {
algorithm = "hash" # fast for probability sampling
seed_base = 54321
quality = "fast"
}Noise in Expressions
Noise functions are available in any expression field. All arguments and results are integers:
| Function | Description |
|---|---|
noise(x, y, z) | Coherent noise at a 3D coordinate |
noise_range(x, y, z, min, max) | Coherent noise mapped into min-max (min <= max) |
perlin(x, y, z) | Perlin noise at a 3D coordinate |
simplex(x, y, z) | Simplex noise at a 3D coordinate |
hash_noise(x, y, z) | Fast hash noise at a 3D coordinate |
Pass 0 for the axes you do not need — a one-dimensional sweep over game time is perlin(var.game_time, 0, 0).
Examples
Scoring Variation
Adding natural variation to a point value with noise:
noise_context "scoring" {
algorithm = "simplex"
seed_base = 12345
quality = "balanced"
}
variable "int" "bumper_base_value" {
initial = 1000
scope = "player"
}
score "event" "bumper_hit" {
when = device.bumper.hit
# Add 0-20% variation to the base bumper value
points = var.bumper_base_value + noise_range(var.ball_time, var.ball_number, 0, 0, var.bumper_base_value / 5)
}Animation
Perlin noise gives a smooth, slowly drifting value suitable for visual effects:
noise_context "animation" {
algorithm = "perlin" # smooth, coherent motion
seed_base = 98765
quality = "high_quality"
}
variable "int" "ambient_glow" {
initial = 0
scope = "global"
# Smoothly varying glow value driven by Perlin noise over game time
formula = perlin(var.game_time, 0, 0) % 1000
}Assembly
An assembly is a reusable, parameterized template for a group of blocks — devices,
variables, scoring rules, and more — that you stamp out once per flipper, target,
or lane. You define the repeated structure once with assembly, mark the parts
that differ as parameters, and instantiate it with a use block.
This page is the block reference. For a step-by-step walkthrough of refactoring a duplicated config into an assembly, see Reusing Config with Assemblies.
assembly "standup" {
parameter "int" "switch_id" { required = true }
parameter "int" "lamp_id" { required = true }
parameter "int" "points" { default = 5000 }
device "switch" "standard" "sw" { id = param.switch_id }
device "light" "standard" "lamp" { id = param.lamp_id }
score "event" "scored" {
when = device.self.sw.hit
points = param.points
}
}
use "standup" "left" {
switch_id = 0x30
lamp_id = 0x50
points = 5000
}
use "standup" "right" {
switch_id = 0x31
lamp_id = 0x51
points = 10000
}Defining an assembly
An assembly block takes one label — its name:
assembly "<name>" {
# parameters, then the blocks to template
}Assembly attributes
| Property | Type | Description |
|---|---|---|
description | string | Human-readable note describing the assembly |
tags | list of strings | Tags applied to every device created by every instance (merged additively with instance and device tags) |
Body
Inside the body you write the blocks the assembly produces, exactly as you would at the top level:
| Block | Notes |
|---|---|
device | device "<category>" "<type>" "<name>" |
variable | variable "<type>" "<name>" |
constant | constant "<type>" "<name>" |
score | score "<type>" "<name>" — event, modifier, or accumulator |
signal | signal "<type>" "<name>" |
event_handler | event_handler "<name>" |
audio_clip | audio_clip "<name>" |
route | Unlabeled; addresses its owner with owners = device.self.<name> |
selection | Unlabeled; same owners = device.self.<name> addressing |
use | A nested instantiation of another assembly |
Reference a parameter with param.<name>, and refer to the assembly’s own blocks
with the self prefix (for example when = device.self.sw.hit, or
condition = var.self.lit == false).
route and selection carry no block label — like their top-level
counterparts, they name what they belong to through owners, which inside an
assembly points at the instance’s own device:
assembly "pop_bumper" {
parameter "int" "switch_id" { required = true }
device "switch" "standard" "sw" { id = param.switch_id }
route {
owners = [device.self.sw]
source "synth" "pop_voice" {
weight = 1
velocity = 1.0
}
}
}Each instance gets its own copy of the route, wired to that instance’s device.
Inside an assembly every owner reference must take the <type>.self.<name> form
and name a block the assembly itself declares — an assembly cannot own another
instance’s blocks. The valid owner types are device, score, modifier,
accumulator, and event_handler. See
Synth for the full route block.
Nested use blocks may go up to ten levels deep.
Parameters
A parameter block declares one input to the assembly. It takes two labels — the
value type and the name:
parameter "<type>" "<name>" {
required = true
}| Property | Type | Description |
|---|---|---|
required | bool | When true, every use must supply this parameter (default false) |
default | (varies) | Value used when a use omits the parameter |
description | string | Human-readable note describing the parameter |
validation | string | Validation rule for the supplied value |
The first label is the parameter’s type. Cade accepts:
| Type | Accepts |
|---|---|
string | A quoted string |
int | A whole number (a fractional value is rejected) |
float | Any number |
bool | true or false |
list | A list value |
map | A map value |
Any other type name is a configuration error.
A parameter is optional when it has a default and required when required = true.
Instantiating with use
A use block stamps out one instance of an assembly. It takes two labels — the
assembly type (its name) and a unique instance name:
use "<assembly>" "<instance>" {
# parameter arguments, plus optional generators
}Supply each parameter as an attribute. Every attribute that is not one of the three reserved meta-arguments below must name a parameter the assembly declares — a typo’d or stray argument is a configuration error, not a silent no-op. The reserved meta-arguments are:
| Meta-argument | Type | Description |
|---|---|---|
count | int | Stamp out N instances; the index is appended to each instance name |
for_each | list or map | Stamp out one instance per element |
tags | list of strings | Tags applied to every device of this instance |
Naming
Each instance’s blocks are prefixed with the instance name and a double underscore.
Given the example above, use "standup" "left" produces devices named
left__sw and left__lamp. Reference an instance’s generated block from outside
the assembly with device.<instance>__<block> — for example device.left__sw.
You can also write the logical form device.assembly.<instance>.<block>, which
Cade rewrites to the expanded <instance>__<block> name during expansion. Both
refer to the same generated device:
signal "combo" "double_flip" {
switches = [
device.assembly.left_flipper.button,
device.assembly.right_flipper.button,
]
time_window = "200ms"
points = 500
}Examples
A flipper assembly
assembly "flipper" {
parameter "int" "button_id" { required = true }
parameter "int" "coil_id" { required = true }
parameter "string" "position" { required = true }
parameter "int" "pulse_ms" { default = 30 }
device "switch" "standard" "button" {
id = param.button_id
}
device "coil" "standard" "power" {
id = param.coil_id
pulse_ms = param.pulse_ms
}
}
use "flipper" "left_flipper" {
button_id = 0x11
coil_id = 0x01
position = "left"
}
use "flipper" "right_flipper" {
button_id = 0x12
coil_id = 0x02
position = "right"
}Generating instances with count
use "trough_switch" "trough" {
count = 4
}This produces the instances trough__0, trough__1, trough__2, and
trough__3 — the index is joined with the same double underscore used for block
names, so a switch named sw inside the assembly becomes trough__0__sw.
Generating instances with for_each
for_each takes a list or a map and stamps out one instance per element, named
<instance>__<key>. With a list, each element is its own key:
use "standup" "bank" {
for_each = ["left", "center", "right"]
switch_id = 0x30
}That produces bank__left, bank__center, and bank__right. With a map, the
keys name the instances and each value’s entries are merged in as per-instance
arguments, overriding anything set on the use block itself:
use "standup" "bank" {
for_each = {
left = { switch_id = 0x30, points = 5000 }
center = { switch_id = 0x31, points = 7500 }
right = { switch_id = 0x32, points = 5000 }
}
}Because the per-iteration values are merged before validation, a map value can
supply a parameter marked required = true. Instances expand in sorted key
order, so the result is deterministic.
Module
A module bundles a game feature — its mode behavior, scoring, variables, event
handling, audio, and light shows — into one self-contained, composable unit.
Modules are how you build modes: there is no standalone top-level mode block, a
module wraps one.
module "multiball" {
description = "3-ball multiball with a rescue jackpot"
mode {
priority = 500
on_start {
do = <<-EOT
set var.balls_in_play = 3
emit multiball_started
EOT
}
}
# Coil pulses and other hardware actions live in a module-level event handler,
# not in the mode body. No condition is needed — a module-scoped handler only
# runs while the module's mode is active.
event_handler "eject_balls" {
when = multiball_started
actions {
pulse_coil "trough_eject" { duration = "50ms" }
}
}
variable "int" "balls_in_play" {
initial = 0
scope = "game"
}
score "event" "jackpot" {
when = device.center_ramp.made
points = 100000
}
}A module that contains a mode block participates in the runtime mode stack. A
module without a mode is always-on infrastructure — use it to group variables,
scoring, and handlers that should run for the whole game.
Syntax
A module block takes one label — its name:
module "<name>" {
# attributes, a mode, and module-scoped blocks
}Module attributes
| Property | Type | Description |
|---|---|---|
description | string | Human-readable note describing the module |
suppressed_by | list of strings | Names of modules that suppress this one while they are active |
The mode block
The mode block makes the module a mode on the priority stack. It is unlabeled —
the module’s name is the mode’s name. A module may contain at most one mode.
mode {
priority = 500
stop_events = ["multiball.ended"]
active_on_startup = false
on_start { do = <<-EOT ... EOT }
on_end { do = <<-EOT ... EOT }
events { /* on "<event>" { do = ... } */ }
timers { /* <name> { duration, ... } */ }
device_control { /* target "device" { select {…} apply {…} } */ }
}A device_control block may also sit inside the mode, where it applies only
while the mode is active. At that scope a condition is redundant and ignored —
the mode being active is the condition — so write conditional device control at
module scope instead.
| Property | Type | Description |
|---|---|---|
priority | int | Stack priority; higher wins when modes overlap. Must be non-negative |
stop_events | list of strings | Events that end the mode when any of them fire |
active_on_startup | bool | When true, the mode is active from the start of a ball (default false) |
description | string | Human-readable note |
Action bodies use do
Every action block inside a mode — on_start, on_end, the on handlers in an
events block, and a timer’s on_expire / on_complete — carries its actions in
a single do heredoc attribute. The heredoc holds command-style statements:
| Statement | Effect |
|---|---|
set var.<name> = <expr> | Assign a variable |
emit <event> | Fire an event |
start_mode "<name>" | Start another mode |
end_mode | End this mode |
on_start {
do = <<-EOT
set var.balls_in_play = 3
set var.ball_save_active = true
emit multiball_started
EOT
}A mode body carries no condition and no inline branching. Hardware actions —
pulsing a coil, flashing a light — and any reaction that needs a condition belong
in a module-level event_handler (see Module-scoped blocks),
gated on the mode being active.
Lifecycle blocks
| Block | Runs |
|---|---|
on_start | Once when the mode activates |
on_end | Once when the mode deactivates |
mode {
priority = 500
on_start {
do = <<-EOT
set var.ball_save_active = true
emit multiball_started
EOT
}
on_end {
do = <<-EOT
set var.ball_save_active = false
set var.balls_in_play = 1
EOT
}
}Scoped events
The events block holds on "<event>" { ... } handlers that are active only
while the mode is running. Each on handler carries a do heredoc:
events {
on "device.left_lock.activated" {
do = <<-EOT
set var.locked_balls = var.locked_balls + 1
EOT
}
on "ball_drained" {
do = <<-EOT
set var.balls_in_play = var.balls_in_play - 1
EOT
}
}Timers
The timers block declares named timers that start when the mode activates and
stop when it ends. Each timer is its own block, named by the block label:
timers {
ball_save {
duration = 15000 # milliseconds
loops = 1 # 1 = one-shot; 0 = repeat forever
on_expire {
do = <<-EOT
set var.ball_save_active = false
EOT
}
}
}| Property | Type | Description |
|---|---|---|
duration | int | Timer length in milliseconds |
loops | int | How many times it fires; 0 means repeat indefinitely (default) |
enabled | bool/string | Whether the timer auto-starts; may be an expression (default true) |
on_expire | block | do heredoc run each time the timer fires |
on_complete | block | do heredoc run when the timer finishes its last loop |
Functions
The functions block defines named action routines callable from the mode’s other
action blocks. Unlike the do action bodies above, a function body is written
inline with command-style statements — including inline if branches:
functions {
check_completion() {
if "var.objectives >= 3" {
end_mode
}
}
}Lifecycle events
Starting and ending a module fires a pair of lifecycle events, both named after the module:
| Event | Fires when |
|---|---|
mode.<module>.started | The mode is pushed onto the active stack |
mode.<module>.ended | The mode is popped off the stack |
module.<module>.started | The module starts (alongside the mode event) |
module.<module>.ended | The module ends (alongside the mode event) |
For a module named multiball, that is mode.multiball.started and
module.multiball.started. Subscribe to these from scoring rules or event
handlers.
stop_events is a separate mechanism: it lists events that end the mode when any
of them fire, and those are ordinarily events you emit (see the stacked-modes
example below, where a handler emits bonus_mode.ended to close the mode out).
Module-scoped blocks
Alongside the mode, a module can declare blocks that belong to the feature:
| Block | Notes |
|---|---|
variable | A variable scoped to the module |
constant | A constant scoped to the module |
score | A scoring rule — event, modifier, or accumulator |
event_handler | An event handler |
audio | Audio bindings (see below) |
shows | Light-show bindings (see below) |
device_control | Enable/suppress devices by tag while the module is active |
Each of mode, stacking, compose, audio, and shows may appear at most
once in a module; device_control is repeatable.
When the module has a mode, its scoring rules, modifiers, accumulators, and
event handlers are all automatically gated on the mode being active — Cade folds
module.<name>.active into each one’s condition, so you never have to write it
yourself. (A module with no mode is always-on infrastructure and is not gated.)
Writing condition = module.self.active explicitly is still allowed and is a
common house style — it makes the gating visible at the point of use. A typical
example is a scoring modifier that applies only while the module runs:
module "frenzy" {
mode { priority = 600 }
score "modifier" "frenzy_boost" {
condition = module.self.active
modify {
playfield_multiplier = 5
}
}
}Controlling devices
A device_control block enables or suppresses a group of devices selected by
tag. Each target block names what it acts on — "device", "module", or
"scoring" — select picks the members by tag, and apply sets what happens to
them:
device_control {
condition = var.tilt_warnings >= 3
target "device" {
select { tags = ["flipper", "coil"] }
apply { enabled = false }
}
}| Block / attribute | Description |
|---|---|
condition | When the control applies. Module scope only — ignored inside a mode |
target "<type>" | What to act on: device, module, or scoring |
select { tags } | List of tags selecting the members |
apply { enabled } | Whether the selected members are enabled |
apply { suppress } | Whether the selected members are suppressed |
enabled and suppress are both optional; omitting one leaves that aspect
untouched rather than defaulting it to false.
Stacking
The stacking block governs how multiple modules interact and whether a module
may run in more than one instance at a time:
stacking {
allow_multiple = true
max_instances = 2
priority_increment = 50
on_max = "queue"
requires = ["base"]
conflicts_with = ["tilt"]
replaces = ["hurry_up"]
}| Property | Type | Description |
|---|---|---|
allow_multiple | bool | Whether more than one instance may be active at once |
max_instances | int | Cap on simultaneous instances |
priority_increment | int | Priority added per stacked instance |
on_max | string | What to do when the cap is reached: "reject" (refuse to start), "queue" (start when a slot frees), or "replace_oldest" (end the oldest instance to make room) |
conflicts_with | list of strings | Modules that cannot be active alongside this one |
requires | list of strings | Modules that must be active for this one to run |
replaces | list of strings | Modules that this one ends when it starts |
Composition
The compose block builds a module from others, optionally overriding parts of
each. lifecycle = "managed" ties the composed modules’ start/stop to the parent:
compose {
lifecycle = "managed"
include "multiball" {
override {
timers {
ball_save { duration = 30000 }
}
audio {
music = "wizard_multiball_theme"
}
}
}
}An override block may adjust the included module’s mode, timers,
variables, scoring, audio, and shows.
Audio and shows
The audio block sets the music played while the mode is active and maps named
clips to events. The shows block binds light shows to lifecycle points:
audio {
music = "multiball_theme"
clips {
start = "multiball_start"
jackpot = "jackpot_fanfare"
}
}
shows {
active = "multiball_flash" # runs while the mode is active
start = "multiball_intro" # plays once on activation
end = "multiball_outro" # plays once on deactivation
}Example: stacked modes with multiplier restore
Three modules layer by priority. The highest-priority active mode’s multiplier wins; when it ends, the multiplier reverts to the next mode down — not to the default.
module "base_mode" {
description = "Base gameplay — 1× scoring multiplier"
mode {
priority = 100
active_on_startup = true
}
score "modifier" "base_multiplier" {
condition = module.self.active
modify {
playfield_multiplier = 100
}
}
}
module "bonus_mode" {
description = "Bonus round — 3× scoring multiplier"
stacking {
requires = ["base_mode"]
}
mode {
priority = 300
stop_events = ["bonus_mode.ended"]
}
score "modifier" "bonus_multiplier" {
condition = module.self.active
modify {
playfield_multiplier = 300
}
}
}
module "frenzy_mode" {
description = "Frenzy — 5× scoring multiplier"
stacking {
requires = ["base_mode"]
}
mode {
priority = 600
stop_events = ["frenzy_mode.ended"]
}
score "modifier" "frenzy_multiplier" {
condition = module.self.active
modify {
playfield_multiplier = 500
}
}
}