NAV

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
  }
}
AttributeDefaultDescription
enabledfalseEnable the web server
host"localhost"Address to bind to
port8080Port to listen on
health { enabled }trueServe the health endpoint
debug { enabled }falseServe the debug endpoints
logging { enabled }falseServe the log stream
livetree { enabled }falseServe 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:

If both HCL and YAML files exist, the HCL file takes precedence.

Configuration Precedence

Settings are resolved from highest to lowest priority:

PrioritySourceExample
1Command-line flags--web-port 9090
2Environment variablesCADE_WEB_PORT=9090
3HCL configuration file.cade.hcl
4YAML configuration file.cade.yaml
5Default valuesBuilt-in defaults

Environment Variables

Environment variables use the CADE_ prefix with underscores representing nested keys:

VariableMaps To
CADE_WEB_ENABLEDweb.enabled
CADE_WEB_PORTweb.port
CADE_WEB_HOSTweb.host
CADE_WEB_HEALTH_ENABLEDweb.health.enabled
CADE_WEB_DEBUG_ENABLEDweb.debug.enabled
CADE_WEB_LOGGING_ENABLEDweb.logging.enabled
CADE_LOGGING_LEVELlogging.level
CADE_LOGGING_FORMATlogging.format
CADE_ANALYTICS_ENABLEDanalytics.enabled
CADE_ANALYTICS_BACKEND_TYPEanalytics.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:

BlockPurpose
devicePhysical hardware declarations (flippers, coils, switches, lights)
global_eventsDeclares table-wide event names, each with an optional priority
signalDetects a pattern of device activity (shot, combo, bank)
flowOrchestrates relationships between events over time
variableGame state variables with types, scopes, and constraints
scoreScoring rules, modifiers, and accumulators
event_handlerGeneral-purpose reactions to events
fragmentReusable configuration snippets (static and dynamic)
pragmaValidation, optimization, and feature control directives
platformPlatform driver configuration (FAST, gRPC, Virtual)
synthOscillator-based synthesis patches (real-time, no files)
assembly / useReusable parameterized block templates and their instances (guide)
moduleBundled 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 == 0

Event-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

DirectiveTypeDefaultDescription
modestring"normal"Validation mode: strict, normal, or relaxed
optimization_levelint2Performance optimization level (0-3)
experimentalboolfalseEnable experimental features
deprecated_okboolfalseAllow deprecated features without errors
errors_as_warningslist[]Error types to downgrade to warnings
disable_checkslist[]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:

These checks cannot be downgraded or disabled:

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

LevelValidationRuntimeSafety
0Full validationNo optimizationsMaximum safety
1Full validationBasic optimizationsHigh safety
2Standard validationStandard optimizationsBalanced
3Minimal validationAggressive optimizationsPerformance first

Component Sub-Blocks

variables

DirectiveTypeDefaultDescription
cache_expressionsbooltrueCache variable expression results
preallocatebooltruePre-allocate variable storage
type_checkingstring"normal"Type checking strictness
max_dependency_depthint10Maximum variable dependency chain depth

scoring

DirectiveTypeDefaultDescription
modestring"normal"Override validation mode for scoring
optimization_levelint2Override optimization for scoring
parallel_evaluationboolfalseEnable parallel scoring evaluation
cache_size_mbint20Scoring expression cache size

events

DirectiveTypeDefaultDescription
modestring"normal"Override validation mode for event handling
parallel_processingboolfalseEnable parallel event handling
max_workersint4Worker pool size for parallel events
queue_sizeint1000Event queue capacity
timeout_msint5000Event processing timeout
dedupe_windowstring"100ms"Event deduplication window (duration string, e.g. "250ms")

devices

DirectiveTypeDefaultDescription
validation_levelstring"normal"Device validation strictness
debounce_defaultsbooltrueApply 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).

AttributeTypeDescription
descriptionstringOptional human-readable note
fragmentslistOther fragments to compose in (see Composition)
paramsblockDynamic fragments only — declares typed parameters
variablesmapReserved structural attribute
(any other)variesA 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 dropped

The 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:

  1. Values from composed fragments
  2. Values from the referenced fragment
  3. 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"
}
PropertyTypeRequiredDescription
initial(varies)NoStarting value; should match the variable type
minnumberNoMinimum bound (numeric types)
maxnumberNoMaximum bound (numeric types)
scopestringNoLifetime scope (see Scopes; default: game)
formulastringNoComputed-variable expression (implies computed = true)
computedboolNoMark as computed (default: false)
decay_ratenumberNoContinuous decay per second toward decay_to
decay_tonumberNoFloor 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

TypeDefault ValueDescription
int0Scoring values, counters, numeric calculations
float0.0Multipliers, physics, fractional values
boolfalseFlags, 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
}
AttributeTypeDescription
min_lengthintMinimum number of characters
max_lengthintMaximum number of characters
patternstringRegular expression the value must match
allowed_valueslistWhitelist of permitted values
case_sensitiveboolWhether 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
}
AttributeTypeApplies toDescription
max_sizeintlist, mapMaximum number of elements
min_sizeintlist, mapMinimum number of elements
unique_elementsboollistReject duplicate elements
allow_emptyboollistWhether an empty list is permitted
sortedboollistKeep 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 attributeTypeRequiredDescription
weightintYesRelative selection weight; must be positive
valueintNoInteger result when this bucket is drawn
labelstringYesHuman-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:

ScopeLifetimeReset TriggerShared Across Players
globalPersists across all gamesPower cycleYes
sessionLasts for one game, shared by all playersGame start / game endYes
gamePersists for one complete gameGame startYes
playerPersists for one player’s gamePlayer startNo
ballLasts for one ballBall startNo

If scope is omitted, the variable is unscoped and behaves as game-scoped — it resets at game start.

Scope Lifecycle

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

FieldTypeDescription
namestringVariable name
old(varies)Previous value
new(varies)New value
scopestringVariable scope (global, session, player, etc.)
reasonstringWhat caused the change

The reason field indicates the mutation source:

ReasonDescription
setExplicit set_variable action in an event handler
toggletoggle_variable action
resetLifecycle reset (game end, ball start, etc.)
player_switchActive 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:

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_active

Expressions with operators or multiple variables are written the same way — bare:

points    = var.base_value * var.multiplier
condition = var.loop_count >= 3

The 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

TypeHolds
intA whole number
booltrue or false
stringText
listA list of values

Attributes

PropertyTypeRequiredDescription
value(varies)YesThe constant’s value; must match the declared type
descriptionstringNoHuman-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

PropertyTypeDefaultDescription
whenstringrequiredEvent that activates this rule — see Naming the event
conditionexpressiontrueBoolean expression that must be true to score
pointsexpressionrequiredExpression calculating the points to award
priorityint100Execution order (lower runs first)
enabledbooltrueWhether this rule is active
emitstring(none)Event emitted after scoring completes
fallback_pointsint(none)Points awarded if the points expression cannot evaluate
fragmentslist(none)Fragments this rule reuses

trigger is an accepted synonym for when. Older tables commonly bind the event with trigger =, and those load. when is canonical — prefer it in new rules. If a rule carries both, when wins 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:

ActionFires on
activated, hit, entered, capturedhit, slingshot, on, pressed, dropped
deactivated, unhit, exited, releasedunhit, off, released, raised
spinspinner rotation
pressedpressed, hit, end-of-stroke
depressed, unpressedreleased, unhit, beginning-of-stroke
droppeddrop-target dropped, hit
raiseddrop-target raised, unhit
clearedhit, slingshot

An unrecognized action silently falls back to hit. A typo such as device.spinner.spinn does 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 }
}

See Routing and selection.

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

PropertyTypeDefaultDescription
typestring"conditional"Modifier type: conditional or timer
conditionexpression(none)Boolean guard for conditional modifiers
whenstring(none)Event that activates this modifier
intervalduration(none)Update interval for timer modifiers
priorityint100Execution order (lower runs first)
enabledbooltrueWhether 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" and score "modifier" — where points, condition, and update values are written bare — an accumulator reads its expression-valued fields as plain values. A bare reference such as collect_multiplier = var.combo_multiplier resolves, 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 to filter, contribute_when, collect_multiplier, and every value inside an on_increment / on_break update {} block — including bare numbers, so use combo_count = "0", not combo_count = 0.

Accumulator Types

TypeDescription
sequenceTracks ordered or windowed event patterns
progressiveBuilds value from a percentage of qualifying scores
thresholdAccumulates until a threshold is reached, then collectable

Properties

PropertyTypeApplies ToDescription
typestringallAccumulator type: sequence, progressive, or threshold
windowdurationsequenceTime window for event matching
patternlistsequenceOrdered event pattern to match
eventslistsequenceEvent glob patterns to collect
filterexpressionsequenceExpression a candidate event must satisfy to count
contribute_percentintprogressivePercentage of each qualifying score to add
contribute_whenexpressionprogressiveCondition for score contribution
thresholdintthresholdValue that triggers collectability
reset_on_collectboolallReset accumulated value after collection
on_collectstringallEvent that triggers collection
collect_multiplierexpressionallMultiplier 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

TypeDetects
shotAn ordered sequence of switch hits (a ramp, orbit, or lane)
comboA series of other completed signals, in order, within a window
bankA group of targets all in the down state

Attributes

PropertyTypeApplies toDescription
switcheslistshotOrdered 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
signalslist of stringscomboOrdered completion-event names that make up the combo
all_downlist of stringsbankTarget device references that must all be down
pointsint or stringallPoints awarded on completion; a string is evaluated as an expression
each_switch_pointsint or stringshotAdditional points awarded per switch in the sequence; same int-or-string rule as points
time_windowstringshot,comboDuration in which the pattern must complete, e.g. "3s", "500ms"
reversibleboolshotWhether the shot also completes when tripped in reverse order
enabledstringallExpression gating the signal; when it evaluates false, the signal is inert

Quote points and each_switch_points when 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. enabled is 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:

BlockFires when
on_completeThe pattern completes successfully
on_progressAn intermediate step of the pattern is reached
on_timeoutThe 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

PropertyTypeRequiredDescription
max_per_gameintNoCaps 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.

PropertyTypeRequiredDescription
scoreintYesThe score a player must reach to earn the reward
rewardstringYesThe 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.

PropertyTypeRequiredDescription
descriptionstringNoHuman-readable note describing the event. For documentation only — it has no runtime effect.
priorityintNoOrders 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:

PrefixDescriptionExamples
switch.*Hardware switch eventsswitch.bumper_1, switch.trough_3
device.*Device hardware events (auto-generated)device.left_spinner.spin
signal.*Signal pattern detection eventssignal.shot.left_orbit.complete
variable.*Variable change events (auto-generated)variable.l6_lit.changed
mode.*Game mode eventsmode.multiball.active
player.*Player-specific eventsplayer.score_changed
system.*System eventssystem.game_start
combo.*Combination/sequence eventscombo.ramp_combo

Device vs signal suffixes

Device events use action-specific suffixes based on device type:

Device TypeSuffixExample
spinner.spindevice.left_spinner.spin
target.hitdevice.center_target.hit
bumper.hitdevice.pop_bumper.hit
slingshot.hitdevice.left_slingshot.hit
button.hitdevice.launch_button.hit
rollover.rolloverdevice.top_rollover.rollover
loop.cleareddevice.left_loop.cleared
ramp.cleareddevice.center_ramp.cleared
orbit.cleareddevice.right_orbit.cleared
opto.cleareddevice.ball_gate.cleared
gate.cleareddevice.outlane_gate.cleared
trough.cleareddevice.trough_1.cleared
switch.activateddevice.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
BlockUse it to
event_handlerRun actions in response to an event
scoreAward points or update variables on an event
flowOrchestrate relationships between events over time

Legacy aliases

global_events is the current spelling. Two older forms still parse for backward compatibility and behave identically:

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:

TypeMatches when
sequencethe listed events occur in the given order
parallelevery listed event occurs, in any order
conditionalthe listed events occur while prerequisites hold
timedthe events occur within the window
state_machinethe events drive a multi-state progression
accumulatorrepeated matches accumulate toward a target

An unknown type is a parse error.

Attributes

PropertyTypeRequiredDescription
eventslistNoThe events the flow watches, in order for a sequence
windowdurationNoTime limit as a Go duration string ("3s", "30s", "500ms"). When it expires before completion, on_timeout fires

Blocks

BlockDescription
on_completeFires when the flow completes
on_timeoutFires when window expires before the flow completes

Each callback accepts:

PropertyTypeDescription
emitevent nameEvent to emit when the callback fires
pointsexpressionPoints 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
  }
}
BlockUse it to
global_eventsDeclare the events a flow watches and emits
event_handlerRun actions in response to an event
scoreAward 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

PropertyTypeRequiredDescription
whenstringYesThe event that triggers the handler
signalstringAlias for when; reads naturally when triggering on a signal completion
eventstringAlias for when
conditionstringNoExpression 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

BlockDescription
actionsThe actions to run when the handler fires (see below)
audioAudio to play when the handler fires

Actions

The actions block holds action verbs. Two kinds, with two syntaxes:

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
}
PropertyTypeDefaultDescription
filestringPath to the audio file, relative to the table directory
preloadboolfalseLoad the file at startup instead of on first play
volumefloat1.0Per-clip volume multiplier (0.01.0)
priorityint500Mixer priority when voices compete; higher wins
loopboolfalseRepeat 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
  }
}
PropertyTypeDescription
clipstringName of the audio_clip to play
volumefloatVolume multiplier for this trigger
priorityintMixer priority for this trigger
pitchstringPitch expression, evaluated at play time
duckingstringChannel or group to duck while this clip plays
duck_levelfloatLevel to duck the ducked channel to (0.01.0)
stackboolAllow overlapping instances of this clip instead of restarting it
stack_limitintMaximum 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 / blockTypeDescription
ownersreferenceOwner(s) this route serves — a single ref or a […] bank; one of device, score, modifier, accumulator, event_handler
source "<kind>" "<name>"blockA candidate. Kind is "audio_clip" or "synth"
mix { channel }blockOptional destination mixer channel

Each source block:

AttributeApplies toDescription
weightany sourceRelative routing weight; higher is chosen more often
pitch"synth" onlyVoice frequency in Hz — number or expression
velocity"synth" onlyVoice amplitude 0.01.0 — number or expression
duration"synth" onlyAuthored hold time, e.g. "0.5s"
sustain"synth" onlyHold the voice until the triggering switch opens; excludes duration
position"synth" onlySub-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

AttributeTypeDescription
ownersreferenceOwner(s) this decision serves
selection_methodstringrandom (default), weighted, rotate, shuffle, balanced, probability, coordinated
no_repeatintAvoid replaying the last N picks
shuffle_memoryintFor shuffle, how many recent picks the bag remembers
clip_probabilityexpressionLive expression biasing the pick toward higher-weighted sources
conditionexpressionGate — 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
  }
}
PropertyTypeDefaultDescription
master_volumefloat0.9Master output level (0.01.0)
device_audiobooltrueEnable 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:

PropertyTypeDescription
clipstringName of the audio_clip to play as music
playliststringName of a music playlist to play
auto_startboolStart the music automatically
envelopestringName of a top-level envelope block shaping fade-in/out
conditionexpressionStart/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
  }
}
AttributeTypeRangeDescription
volumefloat01Channel output level
panfloat-11Stereo 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 typeParameters
reverbfeedback, wet_level
compressorthreshold, ratio, makeup_gain
equalizerlow_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:

AttributeTypeDescription
trackslistClip names in the playlist
modestringSelection: random, weighted, rotate, shuffle, balanced, probability, coordinated
shuffle_memoryintHow many recent tracks to avoid repeating
track_probabilitystringName of a distribution variable driving track choice
crossfadedurationOverlap between tracks, e.g. "2s"
gapdurationSilence 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, not attack = "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 output missing its in) 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

PropertyTypeDefaultDescription
polyphonyint64Number 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
}
PropertyTypeRequiredDefaultDescription
labelstringyesOscillator name; must be unique within the synth
wavestringyesWave shape: "sine", "saw", "square", "triangle"
freqfloatyesBase frequency in Hz
ampfloatno1.0Amplitude; 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
}
PropertyTypeRequiredDefaultDescription
labelstringno"amp"Envelope name; must be unique and not collide with an oscillator name
attacknumberyesAttack duration in milliseconds (e.g., 5)
decaynumberyesDecay duration in milliseconds
sustainfloatyesSustain level, 0.0–1.0
releasenumberyesRelease duration in milliseconds
depthfloatno0Modulation 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
}
PortWireRequiredMeaning
innode.<name>.outyesThe node whose signal becomes the voice output
gainnode.amp.outnoThe 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"
}
PropertyTypeRequiredDescription
sourcestringyesName of a defined oscillator or envelope
targetstringyes"<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
}
PropertyTypeRequiredDefaultDescription
typestringyes"lowpass" or "highpass"
cutofffloatyesCutoff frequency in Hz; must be greater than 0
resonancefloatyesResonance, 0.0–1.0
inwirenoSignal input, node.<name>.out
cutoff_modwirenoControl-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]
}
PropertyTypeRequiredDescription
labelstringyesMixer name; addressable as node.<name>.out
inputslistyesPort-wires to sum — node.<name>.out, or node.<name>.z1 for a feedback tap
gainslistnoPer-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
}
PropertyTypeRequiredDescription
labelstringyesDelay name; addressable as node.<name>.out
inwireyesSignal input, node.<name>.out
timenumberyesDelay time in milliseconds
feedbackfloatnoPortion of the output fed back in, 0.01.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

AttributeTypeDefaultDescription
weightnumber0Relative routing weight when the route lists several sources
pitchexpressionVoice frequency in Hz
velocityexpression1.0Amplitude, 0.01.0
durationstringAuthored hold time (e.g. "0.5s"); releases the tail after it elapses
sustainboolfalseHold the voice until the triggering switch’s open edge instead of a timer
positionblockSpatial 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:

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".

AttributeTypeRequiredDefaultDescription
attacknumberYesTime to rise from silence to full level, in milliseconds
decaynumberYesTime to fall from full level to the sustain level, in milliseconds
sustainnumberYesHeld level after decay, 0.01.0
releasenumberYesTime to fall from the sustain level back to silence, in milliseconds
depthnumberNo0Modulation 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, not attack = "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] }
AttributeTypeRequiredDescription
at[x, y] numberYesNormalized position of this light in the layout (0.01.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.01.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
  }
}
AttributeTypeDefaultDescription
rowsintegerNumber of grid rows (required)
colsintegerNumber 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.01.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] }
  }
}
ElementDescription
labelThe region name
placeOne 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.

AttributeTypeDefaultDescription
modestring"clip" (fixed-length) or "ambient" (runs continuously). Required
durationintegerClip length in milliseconds. Required when mode = "clip"
loopboolWhether a clip repeats. Clip-only — invalid with mode = "ambient"
seedintegerhash of show nameSeed for deterministic noise and random sources

Modes

A show is either a clip or ambient:

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
}
AttributeTypeDescription
fpsintegerSampling rate of source nodes into LUTs
resolution[w, h] integerRaster 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 formExampleMeaning
Literalangle = 90A constant, folded at compile time
Expressionedge = var.intensity * 0.5A live value re-evaluated each tick (references var./event fields)
Port wirein = node.field.outA 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.

KindGroupProduces / does
solidsourceA single constant color across the field
gradientsourceA linear or radial gradient (kind, angle, center, palette)
noisesourceA coherent noise field (type, scale, speed, octaves, seed)
imagesourceA static image sampled onto the layout (src, fit)
spritesourceAn animated gif / sprite sheet (src, fps, frames, loop)
videosourceA video decoded to frames at bake (src, fps, loop)
lfosourceA low-frequency oscillator control value (shape, hz, phase)
timesourceShow time as a control value (mode = seconds or clip-progress)
varinputA game-state variable surfaced as a control value (ref)
colorizetransformMap a scalar field through a palette to color (palette, mode)
levelstransformRemap a scalar field’s range with gamma
thresholdtransformThreshold a scalar field with a soft edge (edge, softness)
gaintransformAdjust brightness / contrast / saturation
hue_shifttransformRotate the hue of a color field (degrees)
warptransformDisplace a color field by a scalar field (amount, axis)
blurtransformBox / Gaussian blur (radius)
remaptransformScroll / rotate / scale / tile coordinates
masktransformMultiply a color field by a scalar mask field
blendcompositeBlend two color fields (mode, factor)
layercompositeStack any number of color fields with a shared blend mode
feedbackfeedbackBlend the previous frame back in for trails / decay (decay)
decayfeedbackA release envelope over a scalar field (release_ms)
outputoutputEmit 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
}
AttributeTypeDefaultDescription
inportThe node output to emit, e.g. node.mix.out. Required
targetlayoutThe layout to render onto, e.g. layout.playfield
priorityinteger0Compositor priority; higher layers paint over lower ones
blendstring"over"Blend mode against layers below (see below)
opacityfloat1.0Layer opacity (0.01.0)

Blend modes

blend (on output, blend, and layer nodes) accepts:

ModeEffect
overNormal alpha compositing (default)
addAdditive — brightens
multiplyMultiplicative — darkens
screenInverse-multiply — brightens, softer than add
maxPer-channel maximum
mixLinear 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

CategoryDescriptionExample Types
switchAll switch typesslingshot, opto, spinner, target, drop, standup, rollover, bumper, button, gate, trough, mechanical
coilAll coil/solenoid typesstandard, flipper, diverter, motor, bumper
flipperFlipper assemblies declared as one unitstandard
lightAll light typesled, rgb, gi_string, flasher
displayAll display typesdmd, lcd, segment
motorStepper and servo motorsstepper, servo
sensorAdvanced sensorsaccelerometer, 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:

PropertyTypeDefaultDescription
idhex/intrequiredHardware identifier
tagslist[]Grouping tags for bulk references
descriptionstring""Human-readable description
part_numberstring""Replacement part number
notesstring""Service notes
enabledbooltrueWhen 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
}
PropertyTypeDefaultDescription
activate_msint2Milliseconds stable before activation
release_msint2Milliseconds stable before release
typestring"active_low"Edge detection type
sample_countint3Required consecutive samples

Automatic Event Generation

Device blocks automatically generate events based on the device type. Events follow the pattern device.<name>.<action>:

CategoryTypeGenerated Events
switchspinnerdevice.<name>.spin
switchtargetdevice.<name>.hit
switchrolloverdevice.<name>.rollover
switchloopdevice.<name>.cleared
switchrampdevice.<name>.cleared
switchorbitdevice.<name>.cleared
switchslingshotdevice.<name>.hit
switchbumperdevice.<name>.hit
switchbuttondevice.<name>.hit
switchoptodevice.<name>.cleared
switchgatedevice.<name>.cleared
switchtroughdevice.<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.

AliasEmitted byMeaning
restedswitch dropTarget has settled in the up position after a reset pulse completes
state_changedany device with explicit stateThe device’s declared state has transitioned (e.g., drop-target FSM)
movedmotor stepper, motor servoThe device has begun moving toward a new target position
reachedmotor stepper, motor servoThe device has arrived at its commanded position
movingmotor stepper, motor servoThe 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-blockFieldTypeDefaultDescription
resetcoildevicerequiredCoil device that physically resets the target
resetpulse_msint30Coil pulse duration when firing the reset
resetdebounce_msint50Quiet 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
  }
}
FieldTypeDescription
homenumberConventional rest/parked position (not required, but widely used as the default start)
anynumberAdditional 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)"
  }
}
PropertyTypeRequiredDescription
onstringYesEvent reference or runtime expression that triggers the action
dostringYesAction 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.

VerbTargetsPurpose
pulse_coilcoil deviceFire a coil for a bounded duration
move_servomotor servo deviceCommand a servo to a named positions {} entry
set_stateany stateful deviceTransition a device’s declared state (e.g., drop-target FSM)
trigger_behaviorany device with behavior {} named blocksFire 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"
  }
}
SettingTypeDescription
strengthintFlipper kick strength (physics-abstract)
hold_timedurationHow 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
  }
}
SettingTypeDescription
kick_angleintKick angle in degrees (physics-abstract)
kick_strengthintKick 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:

PropertyTypeDefaultDescription
connect_timeoutstring"10s"Duration to wait for the initial connection
command_timeoutstring"100ms"Duration to wait for a command to be acknowledged
reconnect_intervalstring"5s"Duration between reconnection attempts
debug_loggingboolfalseLog driver traffic
debug_logstring""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

PropertyTypeDefaultRequiredDescription
net_portstring(none)yesSerial port for the NET processor (the only required attribute in a platform block)
exp_portstring""noSerial port for the EXP processor
baudint921600noSerial baud rate (must be positive)
platformstring"2000"noFAST platform code ("2000" for Neuron)
watchdog_msint1000noWatchdog 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

PropertyTypeDefaultRequiredDescription
portint50051nogRPC server listen port (1-65535)
platform_addressstring""noAddress of an external platform service to connect out to (e.g. "localhost:50052"); empty leaves the driver in server-only mode
connect_timeout_secint10noTimeout in seconds for the initial connection to platform_address (must be at least 1)
enable_gatewaybooltruenoEnable the gRPC-gateway REST proxy
enable_reflectionbooltruenoEnable gRPC server reflection
enable_tlsboolfalsenoEnable TLS encryption
cert_filestring""noPath to the TLS certificate file
key_filestring""noPath to the TLS private key file
enable_corsbooltruenoEnable 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

PropertyTypeDefaultRequiredDescription
switch_countint64noNumber of virtual switches
coil_countint32noNumber of virtual coils
light_countint128noNumber of virtual lights
servo_countint8noNumber 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.

AttributeTypeDescription
namestringContext name (the block label)
algorithmstringNoise algorithm: "perlin", "simplex", "hash", or "white"
seed_baseinteger/stringFixed integer seed, or "deterministic", "time", or "random"
qualitystringAdvisory quality hint: "fast", "balanced", "high_quality"
cache_sizeintegerNumber 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:

AlgorithmCharacterBest for
perlinSmooth, coherent values with natural variationHigh-quality smooth transitions
simplexCoherent like Perlin with better performanceBalanced general-purpose noise
hashGood distribution, less coherence, very fastHigh-frequency sampling (e.g. probability)
whitePure uncorrelated randomnessrandom()-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:

FunctionDescription
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

PropertyTypeDescription
descriptionstringHuman-readable note describing the assembly
tagslist of stringsTags 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:

BlockNotes
devicedevice "<category>" "<type>" "<name>"
variablevariable "<type>" "<name>"
constantconstant "<type>" "<name>"
scorescore "<type>" "<name>"event, modifier, or accumulator
signalsignal "<type>" "<name>"
event_handlerevent_handler "<name>"
audio_clipaudio_clip "<name>"
routeUnlabeled; addresses its owner with owners = device.self.<name>
selectionUnlabeled; same owners = device.self.<name> addressing
useA 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
}
PropertyTypeDescription
requiredboolWhen true, every use must supply this parameter (default false)
default(varies)Value used when a use omits the parameter
descriptionstringHuman-readable note describing the parameter
validationstringValidation rule for the supplied value

The first label is the parameter’s type. Cade accepts:

TypeAccepts
stringA quoted string
intA whole number (a fractional value is rejected)
floatAny number
booltrue or false
listA list value
mapA 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-argumentTypeDescription
countintStamp out N instances; the index is appended to each instance name
for_eachlist or mapStamp out one instance per element
tagslist of stringsTags 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

PropertyTypeDescription
descriptionstringHuman-readable note describing the module
suppressed_bylist of stringsNames 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.

PropertyTypeDescription
priorityintStack priority; higher wins when modes overlap. Must be non-negative
stop_eventslist of stringsEvents that end the mode when any of them fire
active_on_startupboolWhen true, the mode is active from the start of a ball (default false)
descriptionstringHuman-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:

StatementEffect
set var.<name> = <expr>Assign a variable
emit <event>Fire an event
start_mode "<name>"Start another mode
end_modeEnd 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

BlockRuns
on_startOnce when the mode activates
on_endOnce 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
    }
  }
}
PropertyTypeDescription
durationintTimer length in milliseconds
loopsintHow many times it fires; 0 means repeat indefinitely (default)
enabledbool/stringWhether the timer auto-starts; may be an expression (default true)
on_expireblockdo heredoc run each time the timer fires
on_completeblockdo 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:

EventFires when
mode.<module>.startedThe mode is pushed onto the active stack
mode.<module>.endedThe mode is popped off the stack
module.<module>.startedThe module starts (alongside the mode event)
module.<module>.endedThe 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:

BlockNotes
variableA variable scoped to the module
constantA constant scoped to the module
scoreA scoring ruleevent, modifier, or accumulator
event_handlerAn event handler
audioAudio bindings (see below)
showsLight-show bindings (see below)
device_controlEnable/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 / attributeDescription
conditionWhen 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"]
}
PropertyTypeDescription
allow_multipleboolWhether more than one instance may be active at once
max_instancesintCap on simultaneous instances
priority_incrementintPriority added per stacked instance
on_maxstringWhat 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_withlist of stringsModules that cannot be active alongside this one
requireslist of stringsModules that must be active for this one to run
replaceslist of stringsModules 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
    }
  }
}