Introduction
Welcome to the Cade Runtime API reference. This documentation covers the local HTTP surface the runtime exposes for health monitoring and debugging, along with the complete HCL configuration spec used to define tables, devices, scoring, audio, and more.
Base URL
http://localhost:8080
The web server is optional and disabled by default. Enable it with --web-server (short form -w), or with web { enabled = true } in cade.conf, and it binds to localhost:8080. Override the host and port with --web-host / --web-port, or the host and port attributes of the web block.
Authentication
Local connections do not require authentication. The web block exposes its endpoints directly without an auth scheme. For production deployments, bind the server to 127.0.0.1 and leave the debug endpoints disabled:
web {
enabled = true
host = "127.0.0.1"
port = 8080
health {
enabled = true
}
debug {
enabled = false
}
logging {
enabled = false
}
}| Attribute | Default | Description |
|---|---|---|
enabled | false | Enable the web server |
host | "localhost" | Address to bind to |
port | 8080 | Port to listen on |
health { enabled } | true | Serve the health endpoint |
debug { enabled } | false | Serve the debug endpoints |
logging { enabled } | false | Serve the log stream |
livetree { enabled } | false | Serve the live event-tree view |
Each sub-block also has a matching flag — --health-enabled, --debug-enabled, --web-logging, --livetree-enabled — and every setting can be overridden by an environment variable using the CADE_ prefix with dots replaced by underscores (CADE_WEB_ENABLED, CADE_WEB_PORT, CADE_WEB_HEALTH_ENABLED).
Configuration Example
Cade uses HCL-based declarative configuration. Tables are defined in .cade files using composable blocks. Here is a pop bumper with a scoring rule that awards points every time it is hit:
variable "int" "bumper_value" {
initial = 1000
min = 100
max = 10000
scope = "player"
}
device "switch" "bumper" "pop_bumper" {
id = 10
settings {
pulse_ms = 20
pulse_power = 60
recycle_ms = 100
}
}
score "event" "bumper_hit" {
when = device.pop_bumper.hit
points = var.bumper_value
}The when attribute names the event that fires the rule, written bare — device.<name>.hit, where <name> is the device’s third label. Events come from switch-category devices; coil devices are driven by the runtime and do not emit events of their own.
Cade Configuration
The Cade CLI is configured through a layered system that supports HCL files, YAML files, environment variables, and command-line flags. Only the settings you need to change must be specified; all others use sensible defaults.
Configuration Files
Cade looks for configuration in the following locations:
~/.cade.hclor./.cade.hcl(HCL format)~/.cade.yamlor./.cade.yaml(YAML format)
If both HCL and YAML files exist, the HCL file takes precedence.
Configuration Precedence
Settings are resolved from highest to lowest priority:
| Priority | Source | Example |
|---|---|---|
| 1 | Command-line flags | --web-port 9090 |
| 2 | Environment variables | CADE_WEB_PORT=9090 |
| 3 | HCL configuration file | .cade.hcl |
| 4 | YAML configuration file | .cade.yaml |
| 5 | Default values | Built-in defaults |
Environment Variables
Environment variables use the CADE_ prefix with underscores representing nested keys:
| Variable | Maps To |
|---|---|
CADE_WEB_ENABLED | web.enabled |
CADE_WEB_PORT | web.port |
CADE_WEB_HOST | web.host |
CADE_WEB_HEALTH_ENABLED | web.health.enabled |
CADE_WEB_DEBUG_ENABLED | web.debug.enabled |
CADE_WEB_LOGGING_ENABLED | web.logging.enabled |
CADE_LOGGING_LEVEL | logging.level |
CADE_LOGGING_FORMAT | logging.format |
CADE_ANALYTICS_ENABLED | analytics.enabled |
CADE_ANALYTICS_BACKEND_TYPE | analytics.backend_type |
Top-Level Blocks
web
Controls the built-in web server for health monitoring and debugging. For what the endpoints serve and how to monitor a running table, see Web Dashboard & Observability.
web {
enabled = false # Enable the web server (default: false)
host = "localhost" # Bind address (default: "localhost")
port = 8080 # Listen port (default: 8080)
health {
enabled = true # Health monitoring endpoints (default: true)
}
debug {
enabled = false # Debug/visualization endpoints (default: false)
}
logging {
enabled = false # Web server access logging (default: false)
}
}logging
Controls application-level logging.
logging {
level = "info" # Log level: debug, info, warn, error (default: "info")
format = "text" # Log format: text, json (default: "text")
}game
Configures game session parameters.
game {
players = ["player1"] # Player IDs, 1-6 unique, non-empty (default: ["player1"])
max_players = 4 # Max players allowed per game, 1-6 (default: 4)
balls_per_game = 3 # Balls each player gets per game (default: 3)
credits_per_player = 1 # Credits deducted when a player joins (default: 1)
}credits
Configures the credit and coin-op system.
credits {
coins_per_credit = 1 # Coins needed for one credit (default: 1)
max_credits = 99 # Maximum credit balance (default: 99)
free_play = false # Skip credit deductions on game start (default: false)
}audio
Controls the audio subsystem.
audio {
disabled = false # Disable audio entirely (default: false)
}analytics
Configures the analytics and telemetry system.
analytics {
enabled = false # Enable analytics (default: false)
chunk_size_limit = 10485760 # Chunk size in bytes (default: 10MB)
chunk_ttl = "24h" # Chunk time-to-live (default: "24h")
max_disk_usage = 1073741824 # Max disk usage in bytes (default: 1GB)
chunk_storage_path = "/tmp/cade-analytics" # Storage path
realtime_patterns = [ # Events sent immediately
"ACHIEVEMENT_*",
"MODE_*",
"MULTIBALL_*"
]
backend_type = "noop" # noop, memory, logging, http, grpc (default: "noop")
backend_url = "" # Endpoint for http/grpc backends
backend_timeout = "30s" # Request timeout (default: "30s")
realtime_enabled = true # Real-time event dispatch (default: true)
chunking_enabled = true # Chunk-based storage (default: true)
}platform
Declares platform driver instances. See the Platform page for driver-specific settings.
platform "grpc" "vpx_bridge" {
port = 50051
enable_gateway = true
enable_reflection = true
}Minimal Example
A working configuration only needs the settings you want to change. Everything else uses defaults:
web {
enabled = true
}This enables the web server on localhost:8080 with health endpoints active and debug endpoints disabled.
Full Example
web {
enabled = false
host = "localhost"
port = 8080
health {
enabled = true
}
debug {
enabled = false
}
logging {
enabled = false
}
}
logging {
level = "info"
format = "text"
}
game {
players = ["player1"]
max_players = 4
balls_per_game = 3
credits_per_player = 1
}
credits {
coins_per_credit = 1
max_credits = 99
free_play = false
}
audio {
disabled = false
}
analytics {
enabled = false
chunk_size_limit = 10485760
chunk_ttl = "24h"
max_disk_usage = 1073741824
chunk_storage_path = "/tmp/cade-analytics"
realtime_patterns = [
"ACHIEVEMENT_*",
"MODE_*",
"MULTIBALL_*"
]
backend_type = "noop"
backend_url = ""
backend_timeout = "30s"
realtime_enabled = true
chunking_enabled = true
}Table Configuration
Table configuration files (.cade files) define the game-specific behavior of a pinball machine. They use HCL syntax and contain blocks for devices, events, variables, scoring rules, signals, fragments, platform bindings, assemblies, and modules.
Top-Level Blocks
A table configuration is composed of these block types:
| Block | Purpose |
|---|---|
device | Physical hardware declarations (flippers, coils, switches, lights) |
global_events | Declares table-wide event names, each with an optional priority |
signal | Detects a pattern of device activity (shot, combo, bank) |
flow | Orchestrates relationships between events over time |
variable | Game state variables with types, scopes, and constraints |
score | Scoring rules, modifiers, and accumulators |
event_handler | General-purpose reactions to events |
fragment | Reusable configuration snippets (static and dynamic) |
pragma | Validation, optimization, and feature control directives |
platform | Platform driver configuration (FAST, gRPC, Virtual) |
synth | Oscillator-based synthesis patches (real-time, no files) |
constant | Named fixed values referenced by name |
light_layout | Maps light devices onto a normalized 2-D field |
lightshow | Generative node-graph light programs |
display_show | Timed, layered DMD content — text, values, images, animation |
assembly / use | Reusable parameterized block templates and their instances (guide) |
module | Bundled game logic units (mode, variables, scoring, audio) |
Every device is declared with three labels — device "<category>" "<type>" "<name>" — for example device "coil" "bumper" "bumper_1". Variables, scores, signals, and flows take two labels; assemblies, modules, event handlers, lightshows, and display shows take one.
Top-Level Attributes
Alongside the blocks, a table file carries a few plain attributes at the top level:
| Attribute | Type | Description |
|---|---|---|
name | string | Table name |
version | string | Table version |
description | string | Human-readable description |
author | string | Table author |
ad_hoc_switches | list | Switch names the host sends without a device block (see below) |
Ad-hoc host switches
Some hosts send switch events for names no device block declares — a
browser-hosted table’s synthetic tilt switch is the typical case. A
device.<name>.<action> binding to such a switch works, but validation cannot
tell it apart from a typo, so it warns on every load. Declaring the name in
ad_hoc_switches marks the binding as intentional and silences the warning:
ad_hoc_switches = ["tilt"]
event_handler "tilt_warning" {
when = device.tilt.hit
emit = "game.tilt_warning"
}An undeclared switch’s activation arrives as device.<name>.hit (and its
release as device.<name>.released), so bind those actions.
A declared name does not become a device: it gets no autofire rule and no
hardware handshake entry — exactly the two things a synthetic, host-only switch
must not have. Use a real device block for anything physical.
An entry may also be written as a fully qualified device reference, which gives the switch the category and subtype coordinates a bare name has no room for:
ad_hoc_switches = [device.switch.virtual.tilt]Both spellings are accepted permanently — a bare "tilt" behaves exactly as it
always has.
Expression Syntax
The rule of thumb across .cade files is write everything bare; quote only a literal string. Bare names and dotted references are resolved to their underlying value or event name, so quoting them is unnecessary — a quoted form still parses (for back-compat), but bare is the canonical style used throughout this reference.
Expression-valued fields (points, condition, variable references, and update/modify values) are written bare, using the var. prefix for variables. ${...} interpolation is a parse error:
points = var.bumper_value * var.combo_multiplier / 100
condition = var.balls_in_play == 0Event-matching fields (when, signal, event) and reference lists (switches, signals, devices) name an event or device, also written bare:
when = device.bumper_1.hit
switches = [orbit_entry, orbit_exit]Example
This table wires a pop bumper and an orbit shot, awards points, grows a combo multiplier, and flashes lights on a hit:
name = "Ramp Runner"
version = "1.0.0"
# Game session
game {
max_players = 4
balls_per_game = 3
}
# Typed game-state variables with bounds and scope
variable "int" "bumper_value" {
initial = 1000
min = 100
max = 10000
scope = "global"
}
variable "int" "combo_multiplier" {
initial = 100 # 100% = 1.0x
max = 500 # 500% = 5.0x
scope = "ball"
decay_rate = 10
decay_to = 100
}
# Hardware devices: category / type / name
device "coil" "bumper" "bumper_1" {
id = 10
hardware {
coil = "C10"
switch = "S10"
}
settings {
strength = 60
trigger_time = "50ms"
}
}
device "switch" "lane" "left_orbit_entry" {
id = 70
tags = ["orbit", "playfield"]
}
device "switch" "lane" "left_orbit_exit" {
id = 71
tags = ["orbit", "playfield"]
}
# Indicator lights for the bumper
device "light" "led" "b1l1" { id = 128 }
# Table-wide event names
global_events {
bumper_1_hit {
description = "Bumper 1 hit"
}
left_orbit_complete {
description = "Left orbit shot completed"
}
}
# A signal detects a two-switch shot and awards points on completion
signal "shot" "left_orbit" {
switches = [
left_orbit_entry,
left_orbit_exit,
]
time_window = "3s"
points = 2000
each_switch_points = 50
}
# Scoring rule: react to an event and update a variable
score "event" "bumper_hit" {
when = device.bumper_1.hit
points = var.bumper_value * var.combo_multiplier / 100
update {
combo_multiplier = var.combo_multiplier + 10
}
}
# General-purpose reaction: pulse the coil and flash the light on a hit
event_handler "bumper_1_flash" {
when = device.bumper_1.hit
actions {
pulse_coil "bumper_1" {}
flash_light "b1l1" {
duration = "200ms"
}
}
}Reusing Configuration
Two blocks help avoid copy-pasting repeated hardware and logic.
An assembly is a parameterized template stamped out per flipper, target, or lane. Body blocks use param.<name> for the values supplied by each use:
assembly "flipper" {
parameter "int" "button_id" { required = true }
parameter "int" "coil_id" { required = true }
device "switch" "standard" "button" { id = param.button_id }
device "coil" "standard" "power" { id = param.coil_id }
variable "int" "flip_count" {
initial = 0
scope = "ball"
}
}
use "flipper" "left_flipper" {
button_id = 17
coil_id = 1
}
use "flipper" "right_flipper" {
button_id = 18
coil_id = 2
}A module bundles a game feature — a mode plus its scoring, variables, and handlers. The module name is the mode name; a module with no mode block is always-on infrastructure:
module "multiball" {
description = "Multi-ball play"
mode {
priority = 500
}
variable "int" "balls_in_play" {
initial = 0
scope = "global"
}
stacking {
allow_multiple = false
}
}See the Assembly and Module pages for the full attribute reference.
Pragma
The pragma block provides fine-grained control over validation, optimization, and runtime behavior in Cade table configurations. Pragmas enable gradual migration between validation modes, performance tuning, and feature gating.
Syntax
A table has a single pragma block that defines global settings and optional component-specific overrides:
pragma {
mode = "strict"
optimization_level = 2
experimental = false
deprecated_ok = false
errors_as_warnings = ["undefined_variable", "type_mismatch"]
disable_checks = ["expression_complexity"]
variables {
cache_expressions = true
preallocate = true
type_checking = "strict"
max_dependency_depth = 10
}
scoring {
mode = "normal"
optimization_level = 3
parallel_evaluation = true
cache_size_mb = 20
}
events {
mode = "normal"
parallel_processing = true
max_workers = 4
queue_size = 1000
timeout_ms = 5000
dedupe_window = "250ms"
}
devices {
validation_level = "strict"
debounce_defaults = true
}
}The pragma block is read from your .cade table files — not from cade.conf. Cade scans the table directory and the first pragma block it finds applies to the whole table, so keep a single pragma block in one file rather than spreading settings across several. When no table file declares one, the defaults below apply.
Global Directives
| Directive | Type | Default | Description |
|---|---|---|---|
mode | string | "normal" | Validation mode: strict, normal, or relaxed |
optimization_level | int | 2 | Performance optimization level (0-3) |
experimental | bool | false | Enable experimental features |
deprecated_ok | bool | false | Allow deprecated features without errors |
errors_as_warnings | list | [] | Error types to downgrade to warnings |
disable_checks | list | [] | Validation checks to skip |
These six directives are the entire global surface. Caching, pre-allocation, and parallelism are configured per component, in the sub-blocks below.
Downgradable Error Types
These error types can be listed in errors_as_warnings:
undefined_variable– reference to an undefined variabletype_mismatch– type conversion errorsunused_variable– declared but never referenceddeprecated_usage– use of deprecated featuresoverflow_risk– potential integer overflowexpression_complexity– expression exceeds complexity limitsperformance_warning– a performance-related advisorystyle_warning– a style advisory
These checks cannot be downgraded or disabled:
syntax_error,circular_reference,duplicate_definition,missing_required
In strict mode, syntax_error, circular_reference, and duplicate_definition cannot be listed in disable_checks.
Validation Modes
strict
All validation errors are fatal. No undefined variables allowed. No implicit type conversions. All warnings promoted to errors. Maximum runtime safety checks.
normal
Syntax errors are fatal. Type mismatches produce warnings. Undefined variables default to zero values. Basic runtime safety checks. Suitable for active development.
relaxed
Only critical syntax errors are fatal. Minimal type checking. Maximum compatibility. Minimal runtime checks. Suitable for rapid prototyping.
Optimization Levels
| Level | Validation | Runtime | Safety |
|---|---|---|---|
| 0 | Full validation | No optimizations | Maximum safety |
| 1 | Full validation | Basic optimizations | High safety |
| 2 | Standard validation | Standard optimizations | Balanced |
| 3 | Minimal validation | Aggressive optimizations | Performance first |
Component Sub-Blocks
variables
| Directive | Type | Default | Description |
|---|---|---|---|
cache_expressions | bool | true | Cache variable expression results |
preallocate | bool | true | Pre-allocate variable storage |
type_checking | string | "normal" | Type checking strictness |
max_dependency_depth | int | 10 | Maximum variable dependency chain depth |
scoring
| Directive | Type | Default | Description |
|---|---|---|---|
mode | string | "normal" | Override validation mode for scoring |
optimization_level | int | 2 | Override optimization for scoring |
parallel_evaluation | bool | false | Enable parallel scoring evaluation |
cache_size_mb | int | 20 | Scoring expression cache size |
events
| Directive | Type | Default | Description |
|---|---|---|---|
mode | string | "normal" | Override validation mode for event handling |
parallel_processing | bool | false | Enable parallel event handling |
max_workers | int | 4 | Worker pool size for parallel events |
queue_size | int | 1000 | Event queue capacity |
timeout_ms | int | 5000 | Event processing timeout |
dedupe_window | string | "100ms" | Event deduplication window (duration string, e.g. "250ms") |
devices
| Directive | Type | Default | Description |
|---|---|---|---|
validation_level | string | "normal" | Device validation strictness |
debounce_defaults | bool | true | Apply default debounce to all switches |
These four sub-blocks — variables, scoring, events, and devices — are the
only ones a pragma block accepts. Noise behavior is configured per stream with
noise_context blocks instead — a
noise_generation sub-block is rejected as not implemented.
Validation
Pragma blocks are checked when the table loads, and cade validate applies the
same checks — a file that validates clean will not be rejected later by the
runtime over its pragma:
- Unknown blocks are load errors at any depth. The four sub-blocks above
accept only attributes, so any block nested anywhere inside
pragma { }—pragma { debug { } }, or a stray block insidevariables { }— is rejected with a located diagnostic rather than silently dropped. - A malformed attribute expression is a load error. An attribute that fails
to evaluate (an undefined reference,
1 + "abc") is reported with its location, instead of silently behaving as if the attribute were absent and falling back to the default.
Examples
Development Configuration
pragma {
mode = "normal"
experimental = true
errors_as_warnings = ["undefined_variable", "unused_variable"]
variables {
cache_expressions = false
}
scoring {
optimization_level = 1
}
}Production Configuration
pragma {
mode = "strict"
optimization_level = 2
variables {
cache_expressions = true
preallocate = true
type_checking = "strict"
}
scoring {
optimization_level = 3
parallel_evaluation = true
cache_size_mb = 50
}
events {
parallel_processing = false
}
devices {
debounce_defaults = true
validation_level = "strict"
}
}Gradual Migration
Move from legacy to strict validation in phases:
# Phase 1: Identify issues
pragma {
mode = "normal"
errors_as_warnings = ["undefined_variable", "type_mismatch", "deprecated_usage"]
}
# Phase 2: Fix critical issues, remove fixed error types
pragma {
mode = "normal"
errors_as_warnings = ["deprecated_usage"]
}
# Phase 3: Full strict mode
pragma {
mode = "strict"
}Fragment
Fragments are reusable bundles of configuration values that scoring rules pull in by name, so you define a value once and reference it from many rules. There are two kinds: static fragments hold fixed values, and dynamic fragments compute values from parameters supplied at the point of use.
Fragments are applied in score "event" rules through a fragments attribute.
Attributes
A fragment block takes two labels — the kind (static or dynamic) and the
name. A small set of attributes are structural; every other attribute you set
becomes a value the fragment supplies to the rules that reference it (for
example points).
| Attribute | Type | Description |
|---|---|---|
description | string | Optional human-readable note |
fragments | list | Other fragments to compose in (see Composition) |
params | block | Dynamic fragments only — declares typed parameters |
variables | map | Reserved structural attribute |
| (any other) | varies | A value supplied to referencing rules (e.g. points) |
description, fragments, params, and variables are reserved: they configure
the fragment itself and are never passed through as values. Any other attribute
name is.
fragment "static" "combo_scoring" {
description = "Shared combo-target award"
points = 2500
}Static fragments
A static fragment defines fixed values under a static label and a name:
fragment "static" "combo_scoring" {
points = 2500
}A scoring rule that references it inherits those values:
score "event" "combo_hit" {
when = device.combo_target.hit
fragments = ["static.combo_scoring"]
}The rule above awards 2500 points on every combo target hit, with the value
sourced from the fragment. Reference a static fragment by its qualified name
(static.<name>) as a bare string.
Dynamic fragments
A dynamic fragment declares params and computes its values from them. Computed
expressions use the expr( ... ) form:
fragment "dynamic" "scaled_points" {
params {
base = "int"
}
points = expr(var.base * 100)
}Supply the parameters where the fragment is referenced, using the object form of
a reference ({ name = ..., <param> = ... }):
score "event" "ramp_made" {
when = device.ramp.made
fragments = [
{ name = "dynamic.scaled_points", base = 50 },
]
}Here base = 50 resolves expr(var.base * 100) to 5000 points. Expressions
support arithmetic and functions such as min/max:
fragment "dynamic" "capped_combo" {
params {
combo = "int"
}
points = expr(min(var.combo * 1000, 5000))
}params may also be written in attribute form, which is equivalent:
fragment "dynamic" "scaled_points" {
params = { base = "int" }
points = expr(var.base * 100)
}Writing computed expressions
Use the expr( ... ) form for computed values. A bare ${ ... } is not
supported here: ${ ... } is the configuration language’s own interpolation
syntax, so it is evaluated when the file is loaded — before the fragment’s
parameters exist — and the value is dropped (Cade logs a warning). If you prefer
interpolation syntax, escape it as $${ ... }:
points = expr(var.base * 100) # preferred
points = "$${var.base * 100}" # equivalent (escaped interpolation)
points = "${var.base * 100}" # WRONG — evaluated too early, value is droppedThe expr( ... ) form works quoted or unquoted — expr(var.base * 100) and
"expr(var.base * 100)" behave identically.
Composition
A fragment can build on others by listing them in fragments. The composing
fragment inherits their values:
fragment "static" "base_award" {
points = 250
}
fragment "static" "ramp_award" {
fragments = ["static.base_award"]
}A rule that references static.ramp_award receives the inherited points = 250.
Override precedence
Values resolve in a clear order, from lowest to highest priority:
- Values from composed fragments
- Values from the referenced fragment
- Explicit values set directly on the scoring rule
So an explicit value on the rule always wins over a fragment-supplied one. No special syntax is needed — set the value directly on the rule:
fragment "static" "default_award" {
points = 100
}
score "event" "jackpot" {
when = device.jackpot.hit
points = 50000 # wins over the fragment's 100
fragments = ["static.default_award"]
}Examples
Shared scoring constant
Define a point value once and reuse it across rules:
fragment "static" "standard_target" {
points = 1000
}
score "event" "left_target" {
when = device.left_target.hit
fragments = ["static.standard_target"]
}
score "event" "right_target" {
when = device.right_target.hit
fragments = ["static.standard_target"]
}Parameterized award
One dynamic fragment, different values per rule:
fragment "dynamic" "ramp_bonus" {
params {
level = "int"
}
points = expr(var.level * 2500)
}
score "event" "ramp_level_1" {
when = device.ramp.made
condition = var.ramp_level == 1
fragments = [{ name = "dynamic.ramp_bonus", level = 1 }]
}
score "event" "ramp_level_3" {
when = device.ramp.made
condition = var.ramp_level == 3
fragments = [{ name = "dynamic.ramp_bonus", level = 3 }]
}Variable
Variables hold game state values that change during gameplay. Each variable has an explicit type, an initial value, and a scope that determines its lifetime. Variables are referenced in expressions throughout the configuration using the var. prefix.
Declaration Syntax
A variable block requires two labels: the type and the name. The body contains the initial value and optional constraints.
variable "int" "bumper_value" {
initial = 1000
min = 100
max = 10000
scope = "player"
}| Property | Type | Required | Description |
|---|---|---|---|
initial | (varies) | No | Starting value; should match the variable type |
min | number | No | Minimum bound (numeric types) |
max | number | No | Maximum bound (numeric types) |
scope | string | No | Lifetime scope (see Scopes; default: game) |
formula | string | No | Computed-variable expression (implies computed = true) |
computed | bool | No | Mark as computed (default: false) |
decay_rate | number | No | Continuous decay per second toward decay_to |
decay_to | number | No | Floor value for decay; uses min if omitted |
String, list, and map constraints are additional flat attributes on the same block — see the constraint sections below.
Types
| Type | Default Value | Description |
|---|---|---|
int | 0 | Scoring values, counters, numeric calculations |
float | 0.0 | Multipliers, physics, fractional values |
bool | false | Flags, states, conditions |
string | "" | Text display, clip names, identifiers |
list | [] | Collections of typed elements |
map | {} | Key-value associations |
distribution | (none) | Weighted random selection, defined by buckets |
Integer Constraints
variable "int" "bumper_value" {
initial = 1000
min = 100 # Minimum allowed value
max = 10000 # Maximum allowed value
}Float Constraints
variable "float" "ramp_multiplier" {
initial = 1.0
min = 0.5
max = 10.0
}Float variables accept integer values and promote them automatically.
String Constraints
String constraints are flat attributes written directly on the variable
block — not a nested constraints {} sub-block:
variable "string" "player_code" {
initial = "ABC"
scope = "player"
min_length = 2 # Minimum characters
max_length = 8 # Maximum characters
pattern = "^[A-Z0-9]+$" # Regex the value must match
allowed_values = ["ABC", "XYZ"] # Whitelist of valid values
case_sensitive = false # Compare values case-insensitively
}| Attribute | Type | Description |
|---|---|---|
min_length | int | Minimum number of characters |
max_length | int | Maximum number of characters |
pattern | string | Regular expression the value must match |
allowed_values | list | Whitelist of permitted values |
case_sensitive | bool | Whether value comparisons are case-sensitive |
List and Map Constraints
List and map constraints are also flat attributes on the variable block —
there is no nested operations {} sub-block. max_size and min_size apply to
both lists and maps; the remaining attributes are list-only:
variable "list" "combo_tags" {
initial = ["ramp", "loop"]
scope = "global"
max_size = 10 # Maximum elements
min_size = 1 # Minimum elements
unique_elements = true # Reject duplicate values
allow_empty = false # Reject an empty list
sorted = true # Keep elements sorted
}| Attribute | Type | Applies to | Description |
|---|---|---|---|
max_size | int | list, map | Maximum number of elements |
min_size | int | list, map | Minimum number of elements |
unique_elements | bool | list | Reject duplicate elements |
allow_empty | bool | list | Whether an empty list is permitted |
sorted | bool | list | Keep elements in sorted order |
Map Variables
variable "map" "mode_scores" {
initial = {
normal = 1000
multiball = 5000
wizard = 10000
}
scope = "player"
}Distribution Variables
A distribution variable performs weighted random selection. Instead of an
initial value, its body holds a buckets block; each bucket has a relative
weight, an integer value, and a label. Selecting the variable draws a
bucket at random, with higher weights chosen proportionally more often:
variable "distribution" "jackpot_fanfare" {
scope = "player"
buckets {
bucket {
weight = 60
value = 0
label = "normal_fanfare"
}
bucket {
weight = 25
value = 1
label = "extended_fanfare"
}
bucket {
weight = 10
value = 2
label = "epic_fanfare"
}
bucket {
weight = 5
value = 3
label = "legendary_fanfare"
}
}
}| Bucket attribute | Type | Required | Description |
|---|---|---|---|
weight | int | Yes | Relative selection weight; must be positive |
value | int | No | Integer result when this bucket is drawn |
label | string | Yes | Human-readable name for the bucket |
A bucket missing its label, or with a zero or negative weight, is skipped
rather than aborting the variable — the remaining buckets still load.
Scopes
Variables have a scope that controls their lifetime and reset behavior:
| Scope | Lifetime | Reset Trigger | Shared Across Players |
|---|---|---|---|
global | Persists across all games | Power cycle | Yes |
session | Lasts for one game, shared by all players | Game start / game end | Yes |
game | Persists for one complete game | Game start | Yes |
player | Persists for one player’s game | Player start | No |
ball | Lasts for one ball | Ball start | No |
If scope is omitted, the variable is unscoped and behaves as game-scoped —
it resets at game start.
Scope Lifecycle
- Ball start: All
ball-scoped variables reset to their initial values. Player, session, and game variables are unchanged. - Player turn start: On a player’s first turn, both
playerandballvariables initialize to defaults. On subsequent turns, onlyballvariables reset. - Game end:
player,ball, andsessionvariables reset to their initial values.global-scoped variables are preserved. Resets happen during the game-over phase — variable-driven lights and other dependent state clear immediately, not at the next game start. - Game start:
player,ball, andsessionvariables reset again (idempotent safety net).
Session vs Game Scope
The session scope fills a gap between global (persists forever) and player (per-player turn). Session variables reset at game boundaries but are shared across all players within a game — they are not player-keyed. Use session for state that should survive player switches but clear when the game ends.
# Resets every ball
variable "bool" "skill_shot_active" {
initial = true
scope = "ball"
}
# Persists across balls for one player
variable "int" "bonus_multiplier" {
initial = 1
scope = "player"
max = 10
}
# Shared across players, resets each game
variable "int" "table_bonus_level" {
initial = 0
scope = "session"
max = 5
}
# Persists across all games
variable "int" "high_score" {
initial = 0
scope = "global"
}Computed Variables
Computed variables derive their value from an expression referencing other variables. They recalculate automatically when dependencies change. Setting formula marks the variable computed even without an explicit computed = true:
variable "int" "ramp_progressive_value" {
formula = var.ramp_base_value + (var.ramp_count * 1000)
computed = true
}
variable "int" "total_multiplier" {
formula = var.base_multiplier * var.mode_multiplier
computed = true
}Change Events
When a variable’s value changes, Cade emits a variable.<name>.changed event. This allows event handlers to react to variable mutations from any source — gameplay actions, lifecycle resets, or player switches.
Event Shape
| Field | Type | Description |
|---|---|---|
name | string | Variable name |
old | (varies) | Previous value |
new | (varies) | New value |
scope | string | Variable scope (global, session, player, etc.) |
reason | string | What caused the change |
The reason field indicates the mutation source:
| Reason | Description |
|---|---|
set | Explicit set_variable action in an event handler |
toggle | toggle_variable action |
reset | Lifecycle reset (game end, ball start, etc.) |
player_switch | Active player changed; player-scoped value now differs |
Events are only emitted when the value actually changes — a set_variable that writes the same value produces no event.
Synchronizing Lights with Variables
The change event is particularly useful for keeping physical lights in sync with variable state. Without it, lights set by an event handler would go stale on player switches and game-end resets — the variable resets but the light stays in its last state.
An assembly can opt into synchronization by listening for the change event:
assembly "rollover_logic" {
parameter "int" "switch_id" { required = true }
parameter "int" "light_id" { required = true }
parameter "int" "points" { default = 5000 }
device "switch" "standard" "rollover" {
id = param.switch_id
}
device "light" "standard" "lamp" {
id = param.light_id
}
variable "bool" "lit" {
initial = false
scope = "player"
}
score "event" "rollover_scored" {
when = device.self.rollover.hit
points = param.points
}
event_handler "turn_on" {
when = device.self.rollover.hit
condition = var.self.lit == false
actions {
toggle_variable = "self.lit"
set_light "self.lamp" {
state = "on"
}
}
}
event_handler "turn_off" {
when = device.self.rollover.hit
condition = var.self.lit == true
actions {
toggle_variable = "self.lit"
set_light "self.lamp" {
state = "off"
}
}
}
# Re-sync light state when the variable changes for any reason
# (game-end reset, player switch, external set)
event_handler "sync_on" {
when = variable.self.lit.changed
condition = var.self.lit == true
actions {
set_light "self.lamp" {
state = "on"
}
}
}
event_handler "sync_off" {
when = variable.self.lit.changed
condition = var.self.lit == false
actions {
set_light "self.lamp" {
state = "off"
}
}
}
}Inside an assembly, an instance addresses its own generated blocks with the
self prefix — device.self.rollover.hit, var.self.lit — so three instances
of this assembly never cross-talk. See
Assembly.
With this pattern:
- Game end: the
player-scopedlitresets tofalse→ the change event fires →sync_offturns the physical light off - Player switch: P1 has
lit = true, P2 haslit = false→ change event fires → the light reflects the new player’s state - Player restore: when P1’s turn resumes,
litrestores totrue→ the light turns back on
Expression Syntax
Variable References
Variables are referenced with the var. prefix. Expression-bearing attributes
take a bare expression — write it directly, with no quotes or wrapper:
points = var.bumper_value
condition = var.skill_shot_activeExpressions with operators or multiple variables are written the same way — bare:
points = var.base_value * var.multiplier
condition = var.loop_count >= 3The fully-quoted form (condition = "var.loop_count >= 3") still parses for
back-compat, but bare is canonical. The old ${ ... } interpolation form is
no longer accepted in expression fields and is rejected at load.
Operators
# Arithmetic
+ - * / % # Basic math
** # Exponentiation
# Comparison
== != < > <= >= # Comparisons
# Logical
&& || ! # Boolean logic
# Ternary
condition ? true_value : false_value
Built-in Functions
min(a, b, ...) # Minimum value
max(a, b, ...) # Maximum value
abs(x) # Absolute value
clamp(value, min, max) # Constrain to range
round(x, [precision]) # Round to nearest, optional decimal places
floor(x) # Round down
ceil(x) # Round up
sqrt(x) # Square root
pow(base, exponent) # Exponentiation
There is no conditional function — use the ternary operator (condition ? a : b)
instead.
Random, statistical, and noise functions (random, random_range,
weighted_choice, normal, uniform, noise, perlin, …) are also
available; see Noise Context for
the seedable ones. String and list helpers are namespaced — string.length,
string.concat, list.length, list.append, list.random, and so on.
Examples
Integer Percentage Multipliers
Cade uses integer-based percentage multipliers to avoid floating-point precision issues. A value of 100 represents 1.0x:
variable "int" "combo_multiplier" {
initial = 100 # 100% = 1.0x
min = 100 # Never below 1.0x
max = 1000 # Cap at 10.0x
scope = "ball"
}
score "event" "bumper_hit" {
when = device.bumper.hit
points = (var.bumper_value * var.combo_multiplier) / 100
update {
combo_multiplier = min(var.combo_multiplier + 50, 1000)
}
}Decaying Variables
Continuous decay uses decay_rate (a per-second amount) and produces smooth
value changes toward decay_to:
variable "float" "combo_multiplier" {
initial = 1.0
min = 1.0
max = 10.0
scope = "player"
decay_rate = 0.5 # Lose 0.5 per second
decay_to = 1.0 # Stop decaying at 1.0
}For int variables, decay_rate amounts are truncated to integer before application. Decay always respects both decay_to and the variable’s min constraint — whichever is higher acts as the effective floor.
Map Access in Expressions
variable "map" "mode_scores" {
initial = {
normal = 1000
multiball = 5000
}
scope = "player"
}
# Access map value by key
score "event" "mode_bonus" {
when = device.target.hit
points = var.mode_scores[var.current_mode]
}Constant
A constant is a named, fixed value you define once and reference throughout your configuration by name. Unlike a variable, a constant never changes at runtime — it has no scope, no bounds, and no per-ball or per-player lifetime. Use constants for tuning values that several rules share: a base point award, a timer duration, a feature flag.
constant "int" "jackpot_base" {
value = 50000
description = "Starting jackpot value, shared across modes"
}Syntax
A constant block takes two labels — the value type and the name:
constant "<type>" "<name>" {
value = <value>
}Types
| Type | Holds |
|---|---|
int | A whole number |
bool | true or false |
string | Text |
list | A list of values |
Attributes
| Property | Type | Required | Description |
|---|---|---|---|
value | (varies) | Yes | The constant’s value; must match the declared type |
description | string | No | Human-readable note describing the constant |
A constant with no value is a configuration error.
Examples
Shared tuning values
constant "int" "ball_save_ms" {
value = 15000
description = "Ball-save grace period in milliseconds"
}
constant "bool" "tournament_mode" {
value = false
}
constant "string" "default_music" {
value = "main_theme"
}Reference a constant from any expression by its name, the same way you reference a variable.
Inside an assembly or module
Constants can be declared at the top level, inside an assembly body, or inside a module. A constant declared inside a module is scoped to that module.
module "multiball" {
constant "int" "starting_balls" {
value = 3
}
}Score
The scoring system defines how points are awarded, modified, and accumulated during gameplay. Scoring rules compile ahead of play, so awarding points during a game stays fast and predictable. Scoring configuration uses four block types: score "event" for event-triggered point awards, score "signal" for awards triggered by a signal completion, score "modifier" for conditional variable changes, and score "accumulator" for special-purpose score tracking like combos and jackpots.
Any other label is a load error.
"event","signal","modifier", and"accumulator"are the only score block types. An unknown label used to be silently discarded — the file loaded, validation passed, and the block awarded nothing — but now rejects at load with a file/line/column diagnostic. In particular,score "hardware"(which older examples authored withdevices =/tags =selectors and atransition =) was never implemented and is no longer accepted: author ascore "event"whosewhenbinds the device or tag group directly instead —when = tag.pop_bumpers.activatedfor atagsselector,when = device.left_ramp_entry.activatedfor adeviceselector (transition = "active"is theactivatedaction).
Score Event
Score events define point awards triggered by game events. A score event block takes the label "event" and a unique name. It fires whenever the event named by when occurs and its condition (if any) evaluates true.
score "event" "ramp_complete" {
when = device.ramp.cleared
condition = var.multiball_active
points = var.ramp_base_value * var.ramp_count
priority = 50
emit = ramp_scored
update {
ramp_count = var.ramp_count + 1
}
}Properties
| Property | Type | Default | Description |
|---|---|---|---|
when | string | required | Event that activates this rule — see Naming the event |
condition | expression | true | Boolean expression that must be true to score |
points | expression | required | Expression calculating the points to award |
priority | int | 100 | Execution order (lower runs first) |
enabled | bool | true | Whether this rule is active |
emit | string | (none) | Event emitted after scoring completes |
fallback_points | int | (none) | Points awarded if the points expression cannot evaluate |
fragments | list | (none) | Fragments this rule reuses |
trigger,signal, andeventare accepted synonyms forwhen. Older tables commonly bind the event withtrigger =, andsignal =reads naturally on ascore "signal"block; all four spellings load and mean the same thing.whenis canonical — prefer it in new rules. If a rule carries more than one, they resolve in the fixed orderwhen>trigger>signal>event, so the same file parses the same way every time.
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.
Binding a tag group
A rule can also bind every device carrying a tag, with the tag. prefix:
tag.<group>.<action>
Given three bumpers all declaring tags = ["pop_bumpers"]:
score "event" "pop_hit" {
when = tag.pop_bumpers.activated
points = 750
}fires whenever any of them is hit. Prefer a shared tag over an expanded
device list when several devices should score identically — it keeps one rule
per concept, and adding a device to the group later is a one-line tags edit.
Two shapes near this one do not bind, deliberately:
device.tag.<group>.<action>(four segments) is rejected at load — the correct spelling istag.<group>.<action>.device.<group>.<action>where<group>is only a tag does not match — thedevice.prefix resolves names and subtypes, thetag.prefix resolves groups. A device literally namedtagis still bindable asdevice.tag.<action>.
Precedence. A device event fires exactly one rule, chosen by specificity: name > tag > subtype. Explicit beats implicit — a name designates exactly one device, a tag is a group the author deliberately opted the device into, and a subtype is structural (every bumper has it whether or not scoring was on the author’s mind). The winner takes the device wholesale: lower-specificity rules contribute nothing for that device, even for actions the winner does not cover. There is no fan-out — a device never fires both its own rule and its group’s.
When a device belongs to two tagged groups that both have a rule, the
lexicographically first tag wins (the order you wrote the tags = [...] list
does not matter — it reads as an unordered set). Cade logs a warning at load
whenever a tag group is shadowed or does the shadowing — for example when a
device that already has its own named rule joins a tagged group and silently
stops contributing to the group award — so check the log if a group rule seems
to skip a member.
Event actions
The action is the third segment, and it comes from a fixed vocabulary. The mapping below is what the Visual Pinball driver understands:
| Action | Fires on |
|---|---|
activated, hit, entered, captured | hit, slingshot, on, pressed, dropped |
deactivated, unhit, exited, released | unhit, off, released, raised |
spin | spinner rotation |
pressed | pressed, hit, end-of-stroke |
depressed, unpressed | released, unhit, beginning-of-stroke |
dropped | drop-target dropped, hit |
raised | drop-target raised, unhit |
cleared | hit, slingshot |
An unrecognized action silently falls back to
hit. A typo such asdevice.spinner.spinndoes not fail to load and does not warn — it quietly binds to the hit event instead. If a rule fires on the wrong edge, or fires when you expected nothing, check the action spelling against the table above first.
Nested blocks
An update block assigns new values to variables after the award is applied — the right-hand side is an expression evaluated with current game state in scope:
update {
ramp_count = var.ramp_count + 1
combo_multiplier = min(var.combo_multiplier + 50, 1000)
}A structured condition block accepts all_of / any_of lists as an alternative to the scalar condition attribute:
condition {
any_of = [
var.combo_multiplier >= 300,
mode.wizard.active,
]
}To make a scoring rule play a sound, author a top-level route{} (and, if the
rule has more than one candidate clip, a selection{}) that names the rule as
its owner. A nested audio {} block under a score block no longer parses:
route {
owners = [score.ramp_complete]
source "audio_clip" "ramp_award" { weight = 1 }
}Score Signal
A score "signal" block is the same rule shape as score "event" — the same
attributes, the same update block — with a label that documents intent: the
rule scores off a signal completion
rather than a raw device event. Bind the completion event with signal = (or
any of the other synonyms):
signal "shot" "left_orbit" {
switches = [orbit_entry, orbit_exit]
time_window = "3s"
}
score "signal" "orbit_award" {
signal = shot.left_orbit.complete
points = var.orbit_value * var.combo_multiplier / 100
update {
orbit_count = var.orbit_count + 1
}
}Score Modifier
Modifiers change variable values based on conditions or timers. They do not award points directly. A score modifier block takes the label "modifier" and a unique name.
score "modifier" "multiball_bonus" {
type = "conditional"
condition = mode.multiball.active
apply {
bumper_value = var.bumper_value * 2
}
}Properties
| Property | Type | Default | Description |
|---|---|---|---|
type | string | "conditional" | Modifier type: conditional or timer |
condition | expression | (none) | Boolean guard for conditional modifiers |
when | string | (none) | Event that activates this modifier |
interval | duration | (none) | Update interval for timer modifiers |
priority | int | 100 | Execution order (lower runs first) |
enabled | bool | true | Whether this modifier is active |
The variable changes go in an apply block (modify is accepted as a synonym).
A modifier makes sound the same way a score event does — a top-level route{}
with owners = [modifier.<name>].
Examples
# Conditional modifier: doubles values during multiball
score "modifier" "multiball_bonus" {
condition = mode.multiball.active
apply {
bumper_value = var.bumper_value * 2
slingshot_value = var.slingshot_value * 2
base_multiplier = 200
}
}
# Timer modifier: decays combo multiplier every second
score "modifier" "combo_decay" {
type = "timer"
interval = "1s"
apply {
combo_multiplier = max(var.combo_multiplier - 10, 100)
}
}
# Event-triggered modifier: resets state on ball start
score "modifier" "ball_start_reset" {
when = game.ball_start
apply {
skill_shot_active = true
combo_multiplier = 100
ramp_count = 0
}
}Score Accumulator
Accumulators track score across multiple events using patterns like sequences, progressive jackpots, and thresholds. A score accumulator block takes the label "accumulator" and a unique name.
score "accumulator" "bumper_combo" {
type = "sequence"
events = ["bumper.*"]
window = "5s"
on_collect = game.combo_collected
collect_multiplier = var.combo_multiplier
reset_on_collect = true
on_increment {
update {
combo_count = "var.combo_count + 1"
}
}
on_break {
update {
combo_count = "0"
}
}
}Accumulator expressions must be quoted. Unlike
score "event"andscore "modifier"— wherepoints,condition, andupdatevalues are written bare — an accumulator reads its expression-valued fields as plain values. A bare reference such ascollect_multiplier = var.combo_multiplierresolves, but anything containing an operator or a call (var.combo_count + 1,score_event.value >= 5000) does not: it is silently dropped and the field behaves as if it were never set. Write those in quotes. This applies tofilter,contribute_when,collect_multiplier, and every value inside anon_increment/on_breakupdate {}block — including bare numbers, so usecombo_count = "0", notcombo_count = 0.
Accumulator Types
| Type | Description |
|---|---|
sequence | Tracks ordered or windowed event patterns |
progressive | Builds value from a percentage of qualifying scores |
threshold | Accumulates until a threshold is reached, then collectable |
Properties
| Property | Type | Applies To | Description |
|---|---|---|---|
type | string | all | Accumulator type: sequence, progressive, or threshold |
window | duration | sequence | Time window for event matching |
pattern | list | sequence | Ordered event pattern to match |
events | list | sequence | Event glob patterns to collect |
filter | expression | sequence | Expression a candidate event must satisfy to count |
contribute_percent | int | progressive | Percentage of each qualifying score to add |
contribute_when | expression | progressive | Condition for score contribution |
threshold | int | threshold | Value that triggers collectability |
reset_on_collect | bool | all | Reset accumulated value after collection |
on_collect | string | all | Event that triggers collection |
collect_multiplier | expression | all | Multiplier applied at collection time |
Nested blocks
An on_increment block runs each time the accumulator advances, and on_break runs when a sequence is interrupted. Each holds an update block that assigns variables — quoted, per the note above:
on_increment {
update {
combo_count = "var.combo_count + 1"
}
}An accumulator makes sound the same way a score event does — a top-level
route{} with owners = [accumulator.<name>].
Examples
Basic Scoring
variable "int" "bumper_value" {
initial = 1000
min = 100
max = 10000
}
variable "int" "slingshot_value" {
initial = 500
min = 100
max = 5000
}
score "event" "bumper_hit" {
when = device.bumper.hit
points = var.bumper_value
}
score "event" "slingshot_hit" {
when = device.slingshot.hit
points = var.slingshot_value
}Progressive Scoring
variable "int" "combo_multiplier" {
initial = 100
min = 100
max = 1000
scope = "ball"
}
variable "int" "ramp_count" {
initial = 0
scope = "ball"
}
variable "int" "ramp_base_value" {
initial = 5000
}
score "event" "ramp_shot" {
when = device.ramp.cleared
points = (var.ramp_base_value + (var.ramp_count * 1000)) * var.combo_multiplier / 100
update {
ramp_count = var.ramp_count + 1
combo_multiplier = min(var.combo_multiplier + 50, 1000)
}
}Sequence Accumulator
variable "int" "combo_count" {
initial = 0
scope = "ball"
}
score "accumulator" "bumper_combo" {
type = "sequence"
events = ["bumper.*"]
window = "5s"
on_increment {
update {
combo_count = "var.combo_count + 1"
}
}
on_break {
update {
combo_count = "0"
}
}
}Progressive Jackpot
score "accumulator" "super_jackpot" {
type = "progressive"
contribute_percent = 5
contribute_when = "score_event.value >= 5000"
on_collect = game.super_jackpot_collected
collect_multiplier = var.combo_multiplier
reset_on_collect = true
}Signal
A signal detects a pattern of device activity — an ordered shot through a sequence of switches, a combo of completed shots, or a bank of targets all knocked down — and emits a completion event when the pattern is satisfied. Other rules (scoring, event handlers, modes) subscribe to that event.
signal "shot" "left_orbit" {
switches = [orbit_entry, orbit_exit]
time_window = "3s"
points = 2000
}When the ball trips orbit_entry then orbit_exit within three seconds, the
signal completes and awards 2000 points.
Syntax
A signal block takes two labels — the signal type and the name:
signal "<type>" "<name>" {
# ...
}Signal types
| Type | Detects |
|---|---|
shot | An ordered sequence of switch hits (a ramp, orbit, or lane) |
combo | A series of other completed signals, in order, within a window |
bank | A group of targets all in the down state |
Attributes
| Property | Type | Applies to | Description |
|---|---|---|---|
switches | list | shot | Ordered switch references the ball must trip. Write them bare — the name each switch was declared with; a device.-qualified form (e.g. device.orbit_entry) also resolves |
signals | list of strings | combo | Ordered completion-event names that make up the combo |
all_down | list of strings | bank | Target device references that must all be down |
points | int or string | all | Points awarded on completion; a string is evaluated as an expression |
each_switch_points | int or string | shot | Additional points awarded per switch in the sequence; same int-or-string rule as points |
time_window | string | shot,combo | Duration in which the pattern must complete, e.g. "3s", "500ms" |
reversible | bool | shot | Whether the shot also completes when tripped in reverse order |
enabled | string | all | Expression gating the signal; when it evaluates false, the signal is inert |
Quote
pointsandeach_switch_pointswhen they compute. A plain number (points = 2000) works bare, and so does a lone reference (points = var.jackpot). But an expression containing an operator or a call must be written as a quoted string —points = "25000 * var.combo_multiplier". Left bare, it is silently dropped and the signal awards nothing.enabledis the exception: it accepts a bare expression (enabled = var.tournament == false).
Completion handlers
A signal may carry nested action blocks that fire at lifecycle points:
| Block | Fires when |
|---|---|
on_complete | The pattern completes successfully |
on_progress | An intermediate step of the pattern is reached |
on_timeout | The time_window elapses before completion |
signal "combo" "super_combo" {
signals = [shot.left_ramp.complete, shot.left_orbit.complete]
time_window = "5s"
on_complete {
emit = combo.super_combo.complete
}
}A signal may also carry a validation block that rejects physically impossible
input. Its min_time attribute sets the shortest allowed gap between steps — a
sequence completed faster than this is discarded:
signal "shot" "left_ramp" {
switches = [ramp_entry, ramp_exit]
validation {
min_time = "80ms"
}
}Completion events
Each signal emits a completion event named <type>.<name>.complete — for example
shot.left_orbit.complete or bank.drop_targets.complete. Subscribe to it from a
scoring rule or event handler:
score "event" "orbit_award" {
when = shot.left_orbit.complete
points = 5000
}To emit a custom event name instead, set emit inside on_complete.
Examples
One-way ramp shot
A shot that only counts in the forward direction:
signal "shot" "left_ramp" {
switches = [ramp_entry, ramp_exit]
reversible = false
points = 1500
}Drop-target bank
Complete when every drop target in the bank is down:
signal "bank" "drop_targets_complete" {
all_down = ["drop_1", "drop_2", "drop_3"]
points = 10000
}Expression-valued points
Scale the award by a runtime multiplier. An expression that contains an operator must be quoted — see the note under Attributes:
signal "combo" "mega_combo" {
signals = [shot.left_ramp.complete, shot.center_ramp.complete, shot.right_ramp.complete]
time_window = "10s"
points = "25000 * var.combo_multiplier"
}Replay
A replay awards the player something — a credit or an extra ball — when their
score crosses a threshold, the classic “free game for a high score” of mechanical
and solid-state pinball. Define the thresholds once with a top-level replay
block and the runtime grants the award and fires an event each time a player
passes one.
replay {
max_per_game = 1
threshold {
score = 5000000
reward = "credit"
}
threshold {
score = 15000000
reward = "extra_ball"
}
}Syntax
The replay block takes no label — a table has one:
replay {
max_per_game = <n>
threshold {
score = <n>
reward = "<type>"
}
# ... more thresholds
}Attributes
| Property | Type | Required | Description |
|---|---|---|---|
max_per_game | int | No | Caps how many replay awards a single game may grant. Omit for no cap |
Thresholds
Each threshold block defines one score level and the reward granted when a
player’s score crosses it. List as many as you need; they are evaluated
independently.
| Property | Type | Required | Description |
|---|---|---|---|
score | int | Yes | The score a player must reach to earn the reward |
reward | string | Yes | The award type: "credit" or "extra_ball" |
reward must be "credit" or "extra_ball" — any other value is a configuration
error.
Award event
When a player crosses a threshold, the runtime grants the reward and emits a
system.replay.award event. Subscribe to it from an
event handler to react — flash a
lamp, play a fanfare, or update a display:
event_handler "replay_celebration" {
when = system.replay.award
actions {
flash_light "replay_lamp"
}
}Examples
Single replay at a fixed score
replay {
max_per_game = 1
threshold {
score = 2000000
reward = "credit"
}
}Tiered rewards
Award a credit at the first level and an extra ball at a higher one, with no per-game cap:
replay {
threshold {
score = 1000000
reward = "credit"
}
threshold {
score = 3000000
reward = "extra_ball"
}
}Global Events
The global_events block declares the table-wide events your machine
recognizes, optionally giving each one a priority. Think of it as a catalog —
one place to name the events that flow through the game (flipper presses,
plunger fires, nudges, custom game events).
It does not define what happens when an event fires. A global event performs no action on its own; declaring it registers the name (and priority) with the engine. To react to an event, pair it with an event handler or a scoring rule.
global_events {
left_flipper_press {
description = "Left flipper button pressed"
priority = 100
}
plunger_fire {
description = "Plunger released"
}
}Syntax
Each event is a block whose name is the event name — there is no quoted label and no leading keyword:
global_events {
<event_name> {
description = "<text>" # optional
priority = <int> # optional
}
# ... more events ...
}Attributes
Each event entry accepts two optional keys; any other key is ignored.
| Property | Type | Required | Description |
|---|---|---|---|
description | string | No | Human-readable note describing the event. For documentation only — it has no runtime effect. |
priority | int | No | Orders this event relative to others. Defaults to 100. |
Examples
Cataloging input events
The common use is a single block that names the physical inputs and physics
events a table produces, so they read as one catalog. priority is optional —
omit it and the event defaults to 100:
global_events {
# --- Flipper control ---
left_flipper_press {
description = "Left flipper button pressed"
priority = 100
}
left_flipper_release {
description = "Left flipper button released"
priority = 100
}
left_flipper_collide {
description = "Ball collided with left flipper"
}
# --- Plunger ---
plunger_fire {
description = "Plunger released"
}
}Event naming conventions
Events follow a hierarchical dot-separated naming pattern:
| Prefix | Description | Examples |
|---|---|---|
switch.* | Hardware switch events | switch.bumper_1, switch.trough_3 |
device.* | Device hardware events (auto-generated) | device.left_spinner.spin |
signal.* | Signal pattern detection events | signal.shot.left_orbit.complete |
variable.* | Variable change events (auto-generated) | variable.l6_lit.changed |
mode.* | Game mode events | mode.multiball.active |
player.* | Player-specific events | player.score_changed |
system.* | System events | system.game_start |
combo.* | Combination/sequence events | combo.ramp_combo |
Device vs signal suffixes
Device events use action-specific suffixes based on device type:
| Device Type | Suffix | Example |
|---|---|---|
| spinner | .spin | device.left_spinner.spin |
| target | .hit | device.center_target.hit |
| bumper | .hit | device.pop_bumper.hit |
| slingshot | .hit | device.left_slingshot.hit |
| button | .hit | device.launch_button.hit |
| rollover | .rollover | device.top_rollover.rollover |
| loop | .cleared | device.left_loop.cleared |
| ramp | .cleared | device.center_ramp.cleared |
| orbit | .cleared | device.right_orbit.cleared |
| opto | .cleared | device.ball_gate.cleared |
| gate | .cleared | device.outlane_gate.cleared |
| trough | .cleared | device.trough_1.cleared |
| switch | .activated | device.generic_switch.activated |
Coils, lights, flippers, and ball devices produce no device event of their own.
Signal events use the .complete suffix for higher-level pattern detection:
signal.shot.left_orbit.complete
signal.combo.super_jackpot.complete
Variable change events
Variable events use the pattern variable.<name>.changed and are emitted
automatically whenever a variable’s value changes. The event carries a payload
with name, old, new, scope, and reason fields. See
Variable — Change Events for the full event shape and
usage examples.
variable.l6_lit.changed # Boolean rollover light state changed
variable.bonus_multiplier.changed # Player multiplier updated
Related blocks
| Block | Use it to |
|---|---|
event_handler | Run actions in response to an event |
score | Award points or update variables on an event |
flow | Orchestrate relationships between events over time |
Legacy aliases
global_events is the current spelling. Two older forms still parse for
backward compatibility and behave identically:
global_event { ... }— singular block nameevent { ... }— bare singular
Prefer global_events in new tables.
Event Flow
A flow block orchestrates relationships between events over time — a sequence
that must occur in order, a set that can be completed in any order, and so on.
When the flow’s condition is met it fires an on_complete callback; if a time
window is set and expires first, it fires on_timeout.
flow "sequence" "skill_shot" {
events = ["ball_plunged", "upper_loop", "skill_target"]
window = "3s"
on_complete {
emit = skill_shot_made
points = 25000
}
on_timeout {
emit = skill_shot_missed
}
}Syntax
A flow block takes two labels — its type and its name:
flow "<type>" "<name>" {
events = ["<event>", ...] # events the flow watches
window = "<duration>" # optional time limit, e.g. "3s"
on_complete { ... }
on_timeout { ... }
}Flow types
The first label selects the matching strategy:
| Type | Matches when |
|---|---|
sequence | the listed events occur in the given order |
parallel | every listed event occurs, in any order |
conditional | the listed events occur while prerequisites hold |
timed | the events occur within the window |
state_machine | the events drive a multi-state progression |
accumulator | repeated matches accumulate toward a target |
An unknown type is a parse error.
Attributes
| Property | Type | Required | Description |
|---|---|---|---|
events | list | No | The events the flow watches, in order for a sequence |
window | duration | No | Time limit as a Go duration string ("3s", "30s", "500ms"). When it expires before completion, on_timeout fires |
Blocks
| Block | Description |
|---|---|
on_complete | Fires when the flow completes |
on_timeout | Fires when window expires before the flow completes |
Each callback accepts:
| Property | Type | Description |
|---|---|---|
emit | event name | Event to emit when the callback fires |
points | expression | Points to award; may be a runtime expression (var.jackpot_value * 5) |
Examples
Sequence flow
Events must occur in a specific order within a time window:
flow "sequence" "skill_shot" {
events = ["ball_plunged", "upper_loop", "skill_target"]
window = "3s"
on_complete {
emit = skill_shot_made
points = 25000
}
on_timeout {
emit = skill_shot_missed
}
}Parallel flow
Events can occur in any order to complete a set:
flow "parallel" "drop_target_bank" {
events = ["drop_1", "drop_2", "drop_3", "drop_4"]
window = "30s"
on_complete {
emit = bank_cleared
points = 10000
}
}Related blocks
| Block | Use it to |
|---|---|
global_events | Declare the events a flow watches and emits |
event_handler | Run actions in response to an event |
score | Award points or update variables on an event |
Event Handler
An event handler runs a block of actions in response to an event. Where a scoring rule is built around awarding points, an event handler is the general-purpose reaction: pulse a coil, set a variable, play a sound, emit another event.
event_handler "launch_ball" {
when = device.plunger.released
actions {
pulse_coil "trough_eject" {}
}
}Syntax
An event handler takes one label — its name:
event_handler "<name>" {
when = "<event>"
# condition, actions ...
}Attributes
| Property | Type | Required | Description |
|---|---|---|---|
when | string | Yes | The event that triggers the handler |
signal | string | — | Alias for when; reads naturally when triggering on a signal completion |
event | string | — | Alias for when |
condition | string | No | Expression gate; the actions run only when it evaluates true |
when, signal, and event are synonyms for the trigger — use whichever reads
best. Set only one.
Blocks
| Block | Description |
|---|---|
actions | The actions to run when the handler fires (see below) |
A nested
audio {}block no longer parses. A handler that makes a sound is wired by a top-levelroute{}naming it as the owner —owners = event_handler.<name>— with an optionalselection{}deciding between candidate sounds. See Audio. A handler whose only job is a sound needs noactionsblock at all.
Actions
The actions block holds action verbs. There are two syntaxes.
Attribute verbs take a value with =:
| Verb | Value | Effect |
|---|---|---|
emit | event name | Emit another event |
points | number | Award points |
trigger | signal | Fire a signal |
increment | variable | Add one to a variable |
decrement | variable | Subtract one from a variable |
toggle_variable | variable | Flip a boolean variable |
delay | duration | Wait before running the rest of the block, e.g. "1s" |
start_mode | module | Start a module’s mode |
end_mode | module | End a module’s mode |
data | map | Extra payload merged into the emitted event |
Block verbs take braces. Five of them command a device:
| Verb | Commands |
|---|---|
pulse_coil | Fire a coil for a bounded duration |
flash_light | Flash a light |
set_light | Set a light’s state or colour |
display_text | Draw a string on a display |
display_image | Draw an image on a display |
Each of these five accepts two spellings, and both are permanent — the
device may be the block label, or a device attribute inside the block:
# Label form
pulse_coil "reset_coil" {
duration = "50ms"
}
# Attribute form — equivalent
pulse_coil {
device = device.coil.standard.reset_coil
duration = "50ms"
}The attribute form is the one that can carry a fully qualified device
reference: an HCL label is a quoted string or a single bare word, never a dotted
traversal, so pulse_coil device.coil.standard.reset_coil {} will not parse.
The remaining block verbs:
| Verb | Label | Purpose |
|---|---|---|
set_variable | — | Assign a variable a computed value |
eject_ball | — | Eject a ball from a device |
kick_ball | — | Kick a ball from a named kicker |
launch_ball | — | Launch a ball into play |
move_servo | — | Command a servo to a named position or raw angle |
set_state | — | Transition a device’s declared state |
trigger_behavior | — | Fire a named behavior {} on a device |
set_autofire | — | Enable or disable a device’s autofire rule |
music_control | — | Start, stop, or change the background music |
play_show | show | Play a lightshow |
stop_show | show | Stop a lightshow |
display_show | show | Play a display show |
stop_display_show | show | Stop a display show |
The four show verbs are labelled by show, not by device — a show already has a name and already declares its own default display.
event_handler "jackpot_celebration" {
when = "signal.jackpot.complete"
actions {
play_show "jackpot_chase" {
priority = 10
}
display_show "jackpot_banner" {
device = device.display.dmd.main_display
}
}
}
synth_trigger {}is retired. A config containing one fails to load. A synth voice is authored as a top-levelroute{}with asource "synth"— see Synth.
Action verbs may also be written directly in the handler body without an
enclosing actions block; both forms are equivalent:
# Wrapped form
event_handler "score_target" {
when = device.standup.hit
actions {
increment = "targets_hit"
emit = "target_progress"
}
}
# Bare form — equivalent
event_handler "score_target" {
when = device.standup.hit
increment = "targets_hit"
emit = "target_progress"
}Examples
Conditional reaction
Run only when a guard expression holds:
event_handler "auto_relaunch" {
when = ball_drained
condition = var.balls_in_play == 0 && !game.ball_ended
actions {
delay = "1s"
pulse_coil "trough_eject" {}
}
}Triggering on a signal
The signal alias documents intent when the trigger is a signal completion:
event_handler "orbit_lights" {
signal = shot.left_orbit.complete
actions {
flash_light "orbit_arrow" {}
}
}Scoping handlers to a mode
Event handlers can be declared at the top level or inside a
module. A handler inside a module is active
while that module is. For reactions that should only fire while a mode runs, the
module’s mode { events { on "<event>" { ... } } } block is the mode-scoped
equivalent.
Audio
Cade plays recorded sound through clips — .wav or .ogg files you declare
once and trigger from gameplay. This page is the block reference for declaring
clips, triggering them, driving device audio, routing sound through mixer
channels, and scoring background music. For a walkthrough of attaching sound to
devices and scoring, and for real-time synthesis, see the
Audio & Sound guide and
Synth.
Declaring a clip
A top-level audio_clip block defines a named sound. It takes one label — the
name you reference elsewhere:
audio_clip "fx_flipper" {
file = "sounds/fx_flipperup.wav"
preload = true
volume = 0.67
}| Property | Type | Default | Description |
|---|---|---|---|
file | string | — | Path to the audio file, relative to the table directory |
preload | bool | false | Load the file at startup instead of on first play |
volume | float | 1.0 | Per-clip volume multiplier (0.0–1.0) |
priority | int | 500 | Mixer priority when voices compete; higher wins |
loop | bool | false | Repeat the clip until stopped |
Triggering a clip
A clip plays when its owner fires — a device, a scoring rule, a modifier,
an accumulator, or an event handler.
The wiring is a top-level route block that names the owner and lists the
candidate clips; an optional selection block decides which candidate plays:
audio_clip "jackpot_fanfare" {
file = "sounds/jackpot.wav"
}
score "event" "jackpot" {
when = device.center_ramp.made
points = 100000
}
route {
owners = [score.jackpot]
source "audio_clip" "jackpot_fanfare" { weight = 1 }
}A nested
audio {}block no longer parses. Older tables triggered clips with anaudio { clip = … }block nested inside ascore,event_handler, ordeviceblock. That form is now a hard load error, not a silent no-op — the diagnostic points at the replacement: a top-levelroute{}/selection{}pair whoseownersnames the block that used to hold it.
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 {
send {
channel = "effects"
gain = 0.8
}
}
}
selection {
owners = device.pop_bumper_1
selection_method = "weighted"
}route block
| Attribute / block | Type | Description |
|---|---|---|
owners | reference | Owner(s) this route serves — a single ref or a […] bank; one of device, score, modifier, accumulator, event_handler |
source "<kind>" "<name>" | block | A candidate. Kind is "audio_clip" or "synth" |
mix | block | Optional mixer destinations, one send {} sub-block per channel. Takes no attributes of its own |
The mix block holds zero or more send blocks, one per destination mixer
channel and at most one send per
channel. Writing an attribute directly on mix (mix { channel = "effects" })
is a load error.
send attribute | Type | Default | Description |
|---|---|---|---|
channel | string | — | Required. Name of the destination channel or bus |
gain | number | 1.0 | Send level, 0 or greater; omit to send at unity |
A send carrying both attributes needs them on separate lines — HCL’s one-line
block { … } shorthand holds a single attribute, so send { channel = "effects" }
is fine but send { channel = "effects" gain = 0.8 } is a syntax error.
Each source block:
| Attribute | Applies to | Description |
|---|---|---|
weight | any source | Relative routing weight; higher is chosen more often |
pitch | "synth" only | Voice frequency in Hz — number or expression |
velocity | "synth" only | Voice amplitude 0.0–1.0 — number or expression |
duration | "synth" only | Authored hold time, e.g. "0.5s" |
sustain | "synth" only | Hold the voice until the triggering switch opens; excludes duration |
position | "synth" only | Sub-block placing the voice in space: position { x = … y = … z = … } |
A routed "synth" source is the only way to fire a synth patch — see
Synth.
selection block
| Attribute | Type | Description |
|---|---|---|
owners | reference | Owner(s) this decision serves |
selection_method | string | random (default), weighted, rotate, shuffle, balanced, probability, coordinated |
no_repeat | int | Avoid replaying the last N picks |
shuffle_memory | int | For shuffle, how many recent picks the bag remembers |
clip_probability | expression | Live expression biasing the pick toward higher-weighted sources |
condition | expression | Gate — the owner stays silent unless this evaluates true |
Global audio configuration
A single top-level audio block (no label) configures the mixer for the whole
table:
audio {
master_volume = 0.9
device_audio = true
background_music {
clip = main_theme
auto_start = true
}
}| Property | Type | Default | Description |
|---|---|---|---|
master_volume | float | 0.9 | Master output level (0.0–1.0) |
device_audio | bool | true | Enable the device-driven audio system |
Background music
A background_music sub-block selects what plays as the table’s music. Use
clip for a single track or playlist to name a playlist declared in
a music block — the two are mutually exclusive:
| Property | Type | Description |
|---|---|---|
clip | string | Name of the audio_clip to play as music |
playlist | string | Name of a music playlist to play |
auto_start | bool | Start the music automatically |
envelope | string | Name of a top-level envelope block shaping fade-in/out |
condition | expression | Start/stop the music when this evaluates true, e.g. var.in_multiball |
Listener
A listener sub-block places the virtual microphone for spatial audio.
Positioned clips are mixed relative to it; omit it to keep everything on the
flat stereo mix.
audio {
listener {
x = 0.0
y = 0.0
z = 0.0
}
}Channels
A channels sub-block carries per-channel mixer settings. Each entry is a named
channel whose value is an object of settings:
audio {
channels {
effects = {
volume = 0.95
max_voices = 16
}
}
}Sequencing
A sequencing sub-block tunes how routed-audio selection behaves across the
whole table:
audio {
sequencing {
random_seed = 1234567890
}
}| Property | Type | Default | Description |
|---|---|---|---|
global_no_repeat | int | 1 | Avoid replaying the last N picks table-wide |
sequence_memory | int | 5 | How many recent picks selection methods remember |
cooldown_enforcement | bool | true | Enforce per-clip cooldowns |
random_seed | int | (none) | Fixed seed for every routed-audio random pick — set it to make random, weighted, and shuffle selection reproducible across runs (replays, tests). Omit for a fresh sequence each run |
Mixer channels and buses
For DAW-style routing, declare named mixer channel strips and buses at
the top level. A channel is a strip you route sound through; a bus is
structurally identical and is conventionally used as a shared effects
destination. Both carry an optional volume and pan plus an ordered chain of
insert effect blocks:
channel "voice" {
volume = 0.8
pan = 0.0
effect "reverb" {
feedback = 0.6
wet_level = 0.4
}
}
bus "reverb_bus" {
pan = -0.25
effect "compressor" {
threshold = -18
ratio = 4
}
}| Attribute | Type | Range | Description |
|---|---|---|---|
volume | float | 0–1 | Channel output level |
pan | float | -1–1 | Stereo position (-1 left, 1 right) |
Effects run in the order written. Each effect block takes a type label and
numeric parameters; an unknown type or parameter is a config error.
| Effect type | Parameters |
|---|---|
reverb | feedback, wet_level |
compressor | threshold, ratio, makeup_gain |
equalizer | low_gain, mid_gain, high_gain, low_freq, high_freq |
A top-level synth_effects "<name>" block declares the same kind of effect
chain as a reusable, named definition (its parameters may be expressions rather
than fixed numbers) for use with synth
voices.
Music
A top-level music block declares playlists and adaptive layers that a
background_music block can reference. A playlist groups tracks and picks
among them; a layer fades overlay stems in and out over a base track by
condition.
music {
playlist "gameplay" {
tracks = ["theme_a", "theme_b", "theme_c"]
mode = "shuffle"
crossfade = "2s"
track "theme_c" {
condition = var.wizard_mode
weight = 3
tags = ["wizard"]
}
}
layer "intensity" {
base = "gameplay_base"
overlay "drums" {
clip = "layer_drums"
condition = var.multiball_active
fade_in = "1s"
fade_out = "2s"
volume = 0.8
}
}
}Playlist attributes:
| Attribute | Type | Description |
|---|---|---|
tracks | list | Clip names in the playlist |
mode | string | Selection: random, weighted, rotate, shuffle, balanced, probability, coordinated |
shuffle_memory | int | How many recent tracks to avoid repeating |
track_probability | string | Name of a distribution variable driving track choice |
crossfade | duration | Overlap between tracks, e.g. "2s" |
gap | duration | Silence between tracks |
A nested track "<clip>" block refines a single track with a condition
(gate), a weight (for weighted modes), and tags.
Layer attributes: a layer "<name>" has a base clip and one or more
overlay "<name>" sub-blocks, each with clip, condition, fade_in,
fade_out, and volume.
Example
Declare a clip and trigger it from a rule:
audio_clip "target_hit" {
file = "sounds/target.wav"
preload = true
volume = 0.8
}
score "event" "standup_scored" {
when = device.standup.hit
points = 5000
}
route {
owners = [score.standup_scored]
source "audio_clip" "target_hit" { weight = 1 }
}Synth
A synth block defines a procedural sound patch that generates audio at runtime from oscillators, envelopes, and filters — no recorded audio files required. Each synth compiles into a pre-allocated voice pool and is triggered by events.
Declaration Syntax
synth "<name>" {
oscillator "carrier" {
wave = "sine"
freq = 880
}
envelope "amp" {
attack = 2 # milliseconds
decay = 30
sustain = 0.0
release = 50
}
output {
in = node.carrier.out # the voice's signal
gain = node.amp.out # the amplitude VCA
}
}A synth requires at least one oscillator block and exactly one output block that wires the signal path explicitly. An amp envelope is optional: author one to shape the amplitude, or omit it for a minimal patch and cade supplies a default amplitude gate (near-instant attack, full sustain, short release) so a bare oscillator → output still sounds and releases cleanly. An unlabeled envelope is still treated as the "amp" envelope.
The smallest patch that makes a sound is one oscillator wired to the output — no envelope, no gain:
synth "blip" {
oscillator "carrier" {
wave = "sine"
freq = 660
}
output {
in = node.carrier.out
}
}Envelope times are plain numbers in milliseconds. Write
attack = 2, notattack = "2ms". Older string-with-unit values still load, but numbers are the current form.
Incomplete patches parse but don’t compile. A partly-wired synth (say a filter with no signal input, or an
outputmissing itsin) is accepted by the parser and stored — the editor lets you build up a patch step by step — and is rejected only at compile time, when the voice must make sound, with a specific reason. So a config that parses is not guaranteed to play until it compiles cleanly.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
polyphony | int | 64 | Number of simultaneous voices, range 1–128 |
oscillator Block
Each oscillator produces a tone. At least one is required; additional oscillators can act as modulators driving another oscillator’s frequency.
oscillator "carrier" {
wave = "saw"
freq = 440
amp = 1.0
}| Property | Type | Required | Default | Description |
|---|---|---|---|---|
| label | string | yes | — | Oscillator name; must be unique within the synth |
wave | string | yes | — | Wave shape: "sine", "saw", "square", "triangle" |
freq | float | yes | — | Base frequency in Hz |
amp | float | no | 1.0 | Amplitude; for a modulator oscillator, the FM depth in Hz |
envelope Block
Envelopes are named ADSR shapes. The "amp" envelope is the amplitude envelope; authoring one is optional (omit it and cade supplies a default amplitude gate — see above), but you can have at most one. Additional named envelopes can drive pitch or filter cutoff via a modulate block.
envelope "amp" {
attack = 5 # milliseconds
decay = 40
sustain = 0.3
release = 120
}
envelope "pitch" {
attack = 1
decay = 50
sustain = 0.0
release = 10
depth = 400
}| Property | Type | Required | Default | Description |
|---|---|---|---|---|
| label | string | no | "amp" | Envelope name; must be unique and not collide with an oscillator name |
attack | number | yes | — | Attack duration in milliseconds (e.g., 5) |
decay | number | yes | — | Decay duration in milliseconds |
sustain | float | yes | — | Sustain level, 0.0–1.0 |
release | number | yes | — | Release duration in milliseconds |
depth | float | no | 0 | Modulation range for non-amplitude envelopes, in Hz for pitch/cutoff |
The "amp" envelope ignores depth. For other envelopes the effective modulation at any instant is envelope_level × depth.
output Block
Every synth wires its signal path with exactly one output block. It names the node that produces the voice’s sound and, optionally, the envelope that acts as its amplitude VCA:
output {
in = node.carrier.out # or node.filter.out when a filter is in the path
gain = node.amp.out # optional — the "amp" envelope as the output VCA
}| Port | Wire | Required | Meaning |
|---|---|---|---|
in | node.<name>.out | yes | The node whose signal becomes the voice output |
gain | node.amp.out | no | The amplitude envelope driving the output VCA |
Wire gain when you author an amp envelope. Omit it for a minimal patch — cade’s default amplitude gate takes over so the voice still gates and releases.
Port-wires reference a node’s current-sample output as node.<name>.out, where <name> is an oscillator, envelope, mixer, or delay label (the filter node is always node.filter). A node.<name>.z1 port reads the node’s previous sample instead — used for feedback paths. Wiring a port to a node that was never declared is rejected at load, not silently ignored.
modulate Block
A modulate block routes a source (an oscillator or a named envelope) onto a target parameter. Use it for FM modulation between oscillators or to sweep a parameter with an envelope. Zero or more modulate blocks are allowed.
modulate {
source = "mod"
target = "carrier.freq"
}| Property | Type | Required | Description |
|---|---|---|---|
source | string | yes | Name of a defined oscillator or envelope |
target | string | yes | "<osc>.freq" (oscillator pitch) or "filter.cutoff" (envelope only) |
Targeting "filter.cutoff" requires a filter block, and the source must be a named envelope. The modulation graph between oscillators must be acyclic.
filter Block
An optional filter block adds a biquad filter to the voice. At most one filter block per synth. When a filter is in the signal path, wire its input with an in port-wire and route the voice output through it (output { in = node.filter.out … }):
filter {
type = "lowpass"
cutoff = 3000
resonance = 0.7
in = node.carrier.out
}| Property | Type | Required | Default | Description |
|---|---|---|---|---|
type | string | yes | — | "lowpass" or "highpass" |
cutoff | float | yes | — | Cutoff frequency in Hz; must be greater than 0 |
resonance | float | yes | — | Resonance, 0.0–1.0 |
in | wire | no | — | Signal input, node.<name>.out |
cutoff_mod | wire | no | — | Control-rate cutoff offset wire, added to the base cutoff |
To sweep the cutoff with an envelope, add an envelope and a modulate block targeting "filter.cutoff":
envelope "cutoff_env" {
attack = 1
decay = 60
sustain = 0.3
release = 100
depth = 2000
}
filter {
type = "lowpass"
cutoff = 3000
resonance = 0.7
in = node.carrier.out
}
modulate {
source = "cutoff_env"
target = "filter.cutoff"
}mixer Block
A mixer sums several signals into one node, so a patch can layer oscillators
or blend a dry signal with a processed one. Its inputs list holds port-wires;
an optional gains list weights them.
mixer "blend" {
inputs = [node.carrier.out, node.sub.out]
gains = [0.7, 0.3]
}| Property | Type | Required | Description |
|---|---|---|---|
| label | string | yes | Mixer name; addressable as node.<name>.out |
inputs | list | yes | Port-wires to sum — node.<name>.out, or node.<name>.z1 for a feedback tap |
gains | list | no | Per-input gains; must have exactly as many entries as inputs. Omit for an unweighted sum |
delay Block
A delay node feeds a signal back on itself after a fixed time — echo, slapback,
and comb effects.
delay "echo" {
in = node.carrier.out
time = 120 # milliseconds
feedback = 0.4
}| Property | Type | Required | Description |
|---|---|---|---|
| label | string | yes | Delay name; addressable as node.<name>.out |
in | wire | yes | Signal input, node.<name>.out |
time | number | yes | Delay time in milliseconds |
feedback | float | no | Portion of the output fed back in, 0.0–1.0 |
Triggering a Patch
A synth voice is fired through a top-level route block. The route names the
blocks that own the sound (owners) and lists the patch as a source "synth".
This is the only way to author a synth voice — every sound in the table,
recorded or generated, goes through the same routing path.
device "switch" "bumper" "pop_bumper_1" {
id = 32
}
event_handler "bumper_hit_sound" {
when = device.pop_bumper_1.hit
}
route {
owners = [event_handler.bumper_hit_sound]
source "synth" "bumper_hit" {
weight = 1
pitch = 880 # base frequency in Hz
velocity = 1.0 # 0.0–1.0
}
}The second label of the source block is the name of the synth patch to
play. Note that the handler above owns nothing but its route — a handler whose
only job is to make a sound needs no actions block at all.
owners accepts a single reference or a […] list, and each entry is one of
device, score, modifier, accumulator, or event_handler. Owning the
route from a device fires the sound directly off that device’s own events,
with no handler in between.
Synth source attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
weight | number | 0 | Relative routing weight when the route lists several sources |
pitch | expression | — | Voice frequency in Hz |
velocity | expression | 1.0 | Amplitude, 0.0–1.0 |
duration | string | — | Authored hold time (e.g. "0.5s"); releases the tail after it elapses |
sustain | bool | false | Hold the voice until the triggering switch’s open edge instead of a timer |
position | block | — | Spatial placement: position { x = … y = … z = … }, each axis defaulting to 0 |
pitch, velocity, duration, sustain, and position are valid only on a
"synth" source — setting them on an "audio_clip" source is an error.
sustain and duration are mutually exclusive: sustain has no authored
length, so asking for both is rejected at load.
By default a routed synth source is one-shot: the voice plays through attack → decay → release with no held note. Two attributes change that:
durationgives the note an authored hold time.duration = "0.5s"holds the voice for half a second before releasing the tail — good for chimes and pads.sustain = trueholds the voice open until the triggering switch releases, so the sound tracks how long the switch is actually held.
To send the voice to a specific mixer channel,
add an optional mix block to the route. It sits alongside source, not
inside it, and its destinations are send sub-blocks:
route {
owners = device.pop_bumper_1
source "synth" "zap" { pitch = 880 }
mix {
send {
channel = "effects"
gain = 0.8
}
}
}mix takes no attributes of its own: mix { channel = "effects" } fails to
load. Add one send per destination channel (at most one per channel), and
omit gain to send at unity. A send that sets both channel and gain needs
them on separate lines, as above — HCL’s one-line block { … } shorthand holds
a single attribute.
Value Bindings
pitch and velocity accept either a plain number or a bare expression
evaluated against live game state (score, var.<name>, signal.<name>,
event.<field>):
route {
owners = [score.jackpot]
source "synth" "chime" {
weight = 1
pitch = 440 + (score / 1000000) * 220 # rises with score
velocity = min(var.sfx_volume, 1.0)
}
}See Runtime Bindings for the full namespace and evaluation rules, and Routing & Selection for how a route picks between several candidate sources.
Effects
Reusable effect chains — reverb, compression, EQ — are declared once at the top
level with a synth_effects "<name>" block and applied through the mixer. Unlike
a mixer channel’s static chain, a synth_effects parameter may be an expression
that responds to live game state:
synth_effects "ball_fx" {
effect "reverb" {
feedback = var.tension * 0.5
wet_level = 0.3
}
effect "compressor" {
threshold = -20
ratio = 4
}
}The effect types and parameters are the same as a mixer channel’s — see Mixer channels and buses.
Examples
Simple Beep
synth "bumper_beep" {
oscillator "main" {
wave = "sine"
freq = 880
}
envelope "amp" {
attack = 2
decay = 30
sustain = 0.0
release = 50
}
output {
in = node.main.out
gain = node.amp.out
}
}FM Bell Tone
synth "metallic_hit" {
oscillator "carrier" {
wave = "sine"
freq = 660
}
oscillator "mod" {
wave = "sine"
freq = 80
amp = 200 # ±200 Hz modulation depth
}
modulate {
source = "mod"
target = "carrier.freq"
}
envelope "amp" {
attack = 1
decay = 60
sustain = 0.1
release = 150
}
output {
in = node.carrier.out
gain = node.amp.out
}
polyphony = 32
}Filtered Saw with Envelope Sweep
synth "bumper_zap" {
oscillator "carrier" {
wave = "saw"
freq = 880
}
envelope "amp" {
attack = 2
decay = 30
sustain = 0.2
release = 80
}
envelope "pitch" {
attack = 1
decay = 50
sustain = 0.0
release = 10
depth = 400
}
envelope "cutoff_env" {
attack = 1
decay = 60
sustain = 0.3
release = 100
depth = 2000
}
filter {
type = "lowpass"
cutoff = 3000
resonance = 0.7
in = node.carrier.out
}
modulate {
source = "pitch"
target = "carrier.freq"
}
modulate {
source = "cutoff_env"
target = "filter.cutoff"
}
output {
in = node.filter.out
gain = node.amp.out
}
polyphony = 64
}Envelope
A top-level envelope block declares a reusable ADSR shape at config scope —
an attack/decay/sustain/release contour that other parts of the table can
reference by name. It is the same envelope shape used inside a
synth patch, but declared once at the top
level so it can be shared rather than repeated.
The most common use is shaping background music: a
background_music block names an envelope
to fade a track in and out instead of cutting it hard.
Block declaration
envelope "music_fade" {
attack = 800 # milliseconds
decay = 0
sustain = 1.0
release = 1200
}The block takes a single label — the envelope name — used to reference it
elsewhere. If the label is omitted it defaults to "amp".
| Attribute | Type | Required | Default | Description |
|---|---|---|---|---|
attack | number | Yes | — | Time to rise from silence to full level, in milliseconds |
decay | number | Yes | — | Time to fall from full level to the sustain level, in milliseconds |
sustain | number | Yes | — | Held level after decay, 0.0–1.0 |
release | number | Yes | — | Time to fall from the sustain level back to silence, in milliseconds |
depth | number | No | 0 | Modulation range for non-amplitude uses (Hz for pitch/filter). Ignored when the envelope shapes level/volume |
Envelope times are plain numbers in milliseconds. Write
attack = 5, notattack = "5ms"— the string-with-unit form is not a valid envelope time.
attack, decay, sustain, and release are all required; leaving any of them
out is a configuration error.
Using an envelope for music
Reference the envelope by name from a background_music block so the track fades
on start and stop rather than beginning and ending abruptly:
envelope "music_fade" {
attack = 1000
decay = 0
sustain = 1.0
release = 2000
}
audio {
background_music {
playlist = "gameplay"
envelope = "music_fade"
auto_start = true
}
}Here the music ramps up over one second when it starts and fades out over two
seconds when it stops. See the
Audio page for the full
background_music block.
Relationship to synth envelopes
A synth patch declares its own envelope
blocks inside the synth block — an "amp" envelope shapes the voice’s
amplitude (optional; omit it and cade supplies a default amplitude gate), and
named envelopes can drive pitch or filter cutoff through a modulate block. An
unlabeled envelope inside a synth is treated as the "amp" envelope. Those
inline envelopes and this top-level block share the exact same
attributes; the only difference is scope. Declare an envelope at the top level
when more than one place needs the same shape, or to shape background music.
Light Layout
A light_layout block gives your lights a position in space. On their own,
light devices are just addressable channels; a layout places each one on a
normalized 2-D field (x and y from 0.0 to 1.0) so a
lightshow can render a pattern —
a gradient, a swirl, a wipe — across them instead of addressing each light by
hand.
A layout is declared once at the top level and referred to by name from a
lightshow’s output block (target = layout.<name>). A table can declare as
many layouts as it needs — one for the playfield inserts, one for a backbox
strip, and so on.
Block declaration
light_layout "strip" {
place "l0" { at = [0.0, 0.0] }
place "l1" { at = [0.5, 0.0] }
place "l2" { at = [1.0, 0.0] }
}The block takes a single label — the layout name — used to reference it from a
lightshow. Positions are given in normalized layout space: [0.0, 0.0] is one
corner, [1.0, 1.0] the opposite one. A layout defines its positions three
ways, which can be combined in one block: a regular matrix grid, individual
place bindings, and named region groups.
Placing lights individually
A place block positions one light device at a point. Its label is the device
reference, and at is the required [x, y] coordinate:
place "left_ramp_insert" { at = [0.15, 0.60] }| Attribute | Type | Required | Description |
|---|---|---|---|
at | [x, y] number | Yes | Normalized position of this light in the layout (0.0–1.0 each) |
The label is the light device being placed — the same name you declared it with,
for example place "left_ramp_insert". Every placement must resolve to a
declared device "light" ..., and both coordinates must fall within 0.0–1.0;
anything else is a configuration error.
A label may be dotted, in which case only the final segment names the device.
That is how you place a light an assembly
generated: an instance’s blocks are named <instance>__<block>, so a use "insert_bank" "left"
whose assembly declares a light lamp is placed as place "left__lamp".
Laying out a grid
A matrix block lays down a regular grid of virtual sample points in one
step — useful as the raster a show renders across an LED panel or a rectangular
insert bank. Each cell contributes one point at its centre; the cells are not
themselves bound to devices, so a layout that needs to drive real lights still
places them. A layout may declare at most one matrix. rows and cols are
required; origin and size place and scale the grid within the layout’s
normalized space:
light_layout "panel" {
matrix {
rows = 8
cols = 8
origin = [0.10, 0.10] # top-left corner of the grid
size = [0.80, 0.80] # width and height it spans
}
}| Attribute | Type | Default | Description |
|---|---|---|---|
rows | integer | — | Number of grid rows (required) |
cols | integer | — | Number of grid columns (required) |
origin | [x, y] number | [0, 0] | Position of the grid’s first cell |
size | [w, h] number | [1, 1] | Fraction of the layout the grid spans |
rows and cols must be positive, and origin plus size must stay inside the
normalized 0.0–1.0 field — a grid that runs off the edge is rejected.
Grouping lights into regions
A region block gives a name to a set of placed lights so a lightshow can treat
them as a unit. Each region contains its own place blocks:
light_layout "playfield" {
region "left_orbit" {
place "orbit_insert_1" { at = [0.10, 0.30] }
place "orbit_insert_2" { at = [0.10, 0.55] }
}
region "right_ramp" {
place "ramp_insert_1" { at = [0.90, 0.30] }
place "ramp_insert_2" { at = [0.90, 0.55] }
}
}| Element | Description |
|---|---|
| label | The region name |
place | One or more placements, same form as a top-level place |
Editor metadata
A mesh_hint block may carry renderer or editor hints (surface shape, preview
mesh, and similar). It is passed through untouched and ignored by the runtime, so
it never affects playback — you can safely omit it in hand-written tables.
Example
Three RGB lights placed along a strip, ready for a lightshow to sweep across:
device "light" "rgb" "l0" { id = 20 }
device "light" "rgb" "l1" { id = 21 }
device "light" "rgb" "l2" { id = 22 }
light_layout "strip" {
place "l0" { at = [0.0, 0.0] }
place "l1" { at = [0.5, 0.0] }
place "l2" { at = [1.0, 0.0] }
}A lightshow then targets this layout by
name with output { target = layout.strip }.
Lightshow
A lightshow block is a generative light program: a small graph of nodes —
sources, transforms, and composites — that produces color across a
light layout frame by frame. Instead
of scripting each light, you describe how the color is generated (a drifting
noise field, a gradient sweep, a logo image) and Cade renders it deterministically
across the lights the layout places in space.
Shows run on top of your normal lighting: a lightshow composites into a priority
stack, so per-event overrides (a flashing insert on a hit) still paint over the
show. Playback is deterministic — the same seed produces the same frames — so
replays and tests look identical.
Block declaration
lightshow "attract_swirl" {
mode = "ambient"
seed = 42
node "noise" "field" {
scale = 0.3
speed = 1.0
}
node "colorize" "pal" {
in = node.field.out
}
output {
in = node.pal.out
target = layout.playfield
}
}The block takes a single label — the show name. The body holds the show’s
timing attributes, an optional bake block, one or more node blocks wired
together, and one or more output blocks that composite the result onto a
layout.
| Attribute | Type | Default | Description |
|---|---|---|---|
mode | string | — | "clip" (fixed-length) or "ambient" (runs continuously). Required |
duration | integer | — | Clip length in milliseconds. Required when mode = "clip" |
loop | bool | — | Whether a clip repeats. Clip-only — invalid with mode = "ambient" |
seed | integer | hash of show name | Seed for deterministic noise and random sources |
Modes
A show is either a clip or ambient:
clip— a fixed-length animation. It requires aduration(in ms) and may setloopto repeat. Use it for a scored celebration, a mode intro, or a jackpot flourish that plays once and ends.ambient— runs continuously with no fixed length, soloopdoes not apply. Use it for attract-mode backgrounds and idle playfield ambiance.
Bake controls
An optional bake block bounds how source nodes are sampled into lookup tables
before they are mapped onto the layout. Both attributes are optional; leave the
block out to use engine defaults.
bake {
fps = 60 # source sampling rate
resolution = [64, 64] # raster resolution before mapping onto layout coords
}| Attribute | Type | Description |
|---|---|---|
fps | integer | Sampling rate of source nodes into LUTs |
resolution | [w, h] integer | Raster resolution before mapping onto the layout |
Nodes
Each node block is one operation in the show’s graph. It takes two labels —
the node kind (from the catalog below) and a name unique within the show:
node "gradient" "sky" {
kind = "linear"
angle = 90
}Node names must be unique within a show, and the kind must exist in the catalog (an unknown kind is a configuration error). A node’s attributes are its parameters, and each takes one of three forms:
| Parameter form | Example | Meaning |
|---|---|---|
| Literal | angle = 90 | A constant, folded at compile time |
| Expression | edge = var.intensity * 0.5 | A live value re-evaluated each tick (references var./event fields) |
| Port wire | in = node.field.out | A connection from another node’s output port |
Wiring nodes together
Connect nodes by setting an input parameter to another node’s output port, written
bare as node.<name>.<port>:
node "noise" "field" { scale = 0.3 }
node "colorize" "pal" { in = node.field.out } # field.out → pal.in
node "blend" "mix" {
a = node.pal.out
b = node.logo.out
mode = "screen"
}Most inputs are named in; nodes that combine fields use named ports such as
a/b (blend), in/mask (mask), or in/displace (warp). The graph must be
acyclic, except for the feedback node, which is the one kind allowed to
reference the previous frame.
The layer node’s in port is variadic — wire any number of fields into it
by writing a list of port wires, and they stack under the node’s shared blend
mode:
node "layer" "stack" {
in = [node.base.out, node.logo.out, node.sparkle.out]
mode = "screen"
}Node catalog
Nodes are grouped by role. Sources generate a field; transforms reshape one; composites combine two or more; feedback nodes carry state between frames.
| Kind | Group | Produces / does |
|---|---|---|
solid | source | A single constant color across the field |
gradient | source | A linear or radial gradient (kind, angle, center, palette) |
noise | source | A coherent noise field (type, scale, speed, octaves, seed) |
image | source | A static image sampled onto the layout (src, fit) |
sprite | source | An animated GIF or PNG sprite sheet cut into frames cells (src, fit, fps, frames, loop) |
video | source | Video frames (GIF or sprite sheet) baked to a frame sequence (src, fit, fps, loop) |
lfo | source | A low-frequency oscillator control value (shape, hz, phase) |
time | source | Show time as a control value (mode = seconds or clip-progress) |
var | input | A game-state variable surfaced as a control value (ref) |
colorize | transform | Map a scalar field through a palette to color (palette, mode) |
swatch | transform | Sample a palette at a scalar position t, one uniform color across the layout (palette, mode) |
levels | transform | Remap a scalar field’s range with gamma |
threshold | transform | Threshold a scalar field with a soft edge (edge, softness) |
gain | transform | Adjust brightness / contrast / saturation |
hue_shift | transform | Rotate the hue of a color field (degrees) |
warp | transform | Displace a color field by a scalar field (amount, axis) |
blur | transform | Box / Gaussian blur (radius) |
remap | transform | Scroll / rotate / scale / tile coordinates |
mask | transform | Multiply a color field by a scalar mask field |
trim | transform | Scale the brightness of selected devices, leaving the rest untouched (devices, tags, brightness) |
blend | composite | Blend two color fields (mode, factor) |
layer | composite | Stack any number of color fields with a shared blend mode |
feedback | feedback | Blend the previous frame back in for trails / decay (decay) |
decay | feedback | A release envelope over a scalar field (release_ms) |
output | output | Emit a color field to a layout (see below) |
Two of the transforms deserve a note:
swatchpicks one color for the whole layout by sampling itspaletteat a scalart(mode=clamporrepeatcontrols out-of-ranget).tis an input port, not a parameter — wire it from a scalar node (lfo,time,var), e.g.t = node.pulse.out; a literal liket = 0.5is not accepted (route a constant through avarnode instead).trimis a device-addressed gain:devicesand/ortags(lists of strings) select which lights it scales, andbrightness(0.0–4.0, default1.0) is the factor — it accepts a literal, an expression, or a wire from a scalar node, so a trim can be driven at runtime. With both selector lists empty it selects nothing and passes the field through unchanged.
The image, sprite, and video sources share a fit parameter —
contain, cover, stretch (the default), or tile — controlling how the
artwork maps onto the layout.
Output
An output block composites a node’s color onto a layout as one layer of the
priority stack. A show can have several outputs — for example one per layout, or
several priorities onto the same layout.
output {
in = node.mix.out
target = layout.playfield
priority = 10
blend = "screen"
opacity = 0.8
}| Attribute | Type | Default | Description |
|---|---|---|---|
in | port | — | The node output to emit, e.g. node.mix.out. Required |
target | layout | — | The layout to render onto, e.g. layout.playfield |
priority | integer | 0 | Compositor priority; higher layers paint over lower ones |
blend | string | "over" | Blend mode against layers below (see below) |
opacity | float | 1.0 | Layer opacity (0.0–1.0) |
Blend modes
blend (on output, blend, and layer nodes) accepts:
| Mode | Effect |
|---|---|
over | Normal alpha compositing (default) |
add | Additive — brightens |
multiply | Multiplicative — darkens |
screen | Inverse-multiply — brightens, softer than add |
max | Per-channel maximum |
mix | Linear interpolation by factor |
Editor metadata
An editor block stores canvas positions for the visual show editor:
editor {
node "field" { at = [40, 40] }
node "pal" { at = [220, 40] }
}It is captured verbatim and ignored by the runtime, so it never affects playback. Hand-written tables can leave it out entirely.
Example
A complete ambient show: a solid red source composited onto a strip layout.
device "light" "rgb" "l0" { id = 20 }
device "light" "rgb" "l1" { id = 21 }
device "light" "rgb" "l2" { id = 22 }
light_layout "strip" {
place "l0" { at = [0.0, 0.0] }
place "l1" { at = [0.5, 0.0] }
place "l2" { at = [1.0, 0.0] }
}
lightshow "preview" {
mode = "ambient"
node "solid" "base" {
color = "#ff0000"
}
output {
in = node.base.out
target = layout.strip
blend = "over"
}
}See Light Layout for placing the lights a show renders onto.
Display Show
A display_show is authored DMD content: a timed, layered document of text,
values, images and animations that plays on a display device. It is the display
counterpart of a lightshow — a
lightshow paints lamps, a display show paints pixels.
device "display" "dmd" "main_display" {
id = 30
dimensions {
width = 128
height = 32
}
}
display_show "attract" {
duration = 4000
loop = true
device = "main_display"
layer "text" {
element "text" "title" {
text = "RAMP RUNNER"
x = 8
y = 4
}
}
}Block declaration
A display show takes one label — its name:
display_show "<name>" {
duration = <milliseconds>
# loop, device, priority, blend, opacity, stop_when
# trigger { } ...
# layer "<name>" { } ...
}| Attribute | Type | Default | Description |
|---|---|---|---|
duration | integer | — | Show length in milliseconds. Required and must be greater than zero |
loop | bool | false | Restart at duration instead of ending |
device | string | — | Default target display. Authoring intent only — a trigger verb may override it |
priority | integer | 0 | Orders this show against other shows on the same device; higher sits on top |
blend | string | "max" | How the show’s composed frame folds into the device stack |
opacity | float | 1.0 | Master multiplier, 0.0–1.0 |
stop_when | list | [] | Event names that retract the show |
Every time value on this block and its elements is in milliseconds.
device accepts either a plain name ("main_display") or a fully qualified
device reference (device.display.dmd.main_display). It is not resolved when
the file is read, so a show and its display may live in different files.
priority, blend and opacity describe how the show sits in the device
stack — several shows can target one panel at once. A layer’s own blend and
opacity (below) compose inside the show; these compose the finished frame
on top of whatever else is on the panel.
Blend modes
Both the show-level blend and a layer’s blend take one of six names:
| Mode | Effect |
|---|---|
over | Normal compositing — the top frame replaces |
add | Additive — brightens |
multiply | Multiplicative — darkens |
screen | Inverse-multiply — brightens, softer than add |
max | Per-channel maximum (default) |
mix | Linear interpolation |
The default is max, not over. On a shade plane, shade 0 is opaque black
rather than transparent, so a text layer composited over artwork at full
opacity would replace the whole frame instead of drawing on top of it.
Triggers
A trigger block starts the show when an event fires. It takes no label and may
be repeated — a show can declare several criteria.
display_show "jackpot_flash" {
duration = 1500
device = "main_display"
trigger {
when = "signal.jackpot.complete"
condition = var.multiball_active
}
stop_when = ["mode.multiball.ended"]
layer "text" {
element "text" "banner" {
text = "JACKPOT"
x = 24
y = 8
}
}
}| Attribute | Type | Required | Description |
|---|---|---|---|
when | string | Yes | The event that starts the show |
condition | string | No | Guard expression; the show starts only when it evaluates true |
when and condition use exactly the same vocabulary as an
event handler — a trigger is
shorthand for a handler whose only action is to start this show.
A show with no trigger block is inert until something starts it: the
display_show action verb, or a host calling the runtime directly.
stop_when lists event names that retract the show. A non-looping show retires
itself when its duration elapses, so stop_when is for cutting a timed show
short and for ending a looping one.
Layers
A show’s content lives in ordered layer "<name>" blocks. Each layer composites
into the show’s frame in declaration order, and holds the elements that draw
into it.
layer "background" {
priority = 0
blend = "max"
opacity = 1.0
element "image" "backdrop" {
source = "attract-bg.png"
}
}| Attribute | Type | Default | Description |
|---|---|---|---|
priority | integer | 0 | Order within the show; higher paints later |
blend | string | "max" | Blend mode against the layers below |
opacity | float | 1.0 | Layer opacity, 0.0–1.0 |
Layer names must be unique within a show. Element names must be unique within a layer, but two layers may each hold an element of the same name.
Elements
An element takes two labels — its kind and its name:
element "<kind>" "<name>" { ... }| Kind | Draws |
|---|---|
text | A string, resolved once when the show starts |
value | A string re-resolved every tick, so a climbing score keeps climbing |
image | A still image from the asset store |
animation | An animated GIF, or a PNG sprite sheet cut into cells |
text and value are the same shape and differ only in when their string is
resolved. Use value for anything bound to live game state.
Common attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
at | integer | 0 | Start offset from the show’s start, in milliseconds |
for | integer | — | How long the element stays on, in milliseconds. Omit to run to duration |
visible | bool | true | Set false to mute the element without deleting it |
x | integer | 0 | Left edge within the device frame |
y | integer | 0 | Top edge within the device frame |
Text and value elements
| Attribute | Type | Default | Description |
|---|---|---|---|
text | string | — | The string to draw. Required |
length | integer | 0 | Reserved field width, in glyph cells |
align | string | "left" | left, right or center within the reserved field |
length reserves a field so an authoring tool can draw the exact footprint a
value will occupy. It never truncates: an overlong string keeps every glyph and
clips at the plane edge. align requires a length — there is no field to
align within otherwise, and it is rejected rather than silently doing nothing.
Right alignment in a fixed field is what a real score readout does, and it stops digits jittering as the score climbs:
element "value" "player_score" {
text = "${score}"
x = 2
y = 20
length = 9
align = "right"
}A text or value element cannot set width, height or fit — those size a
pixel box, and a text field has none.
Image and animation elements
| Attribute | Type | Default | Description |
|---|---|---|---|
source | string | — | Asset name resolved through the asset store. Required |
width | integer | plane width | Box width in device pixels |
height | integer | plane height | Box height in device pixels |
fit | string | "stretch" | contain, cover, stretch or tile |
fps | float | — | Animation playback rate. Animation only |
loop | bool | false | Wrap past the last frame instead of holding it. Animation only |
sheet_frames | integer | — | Cut a PNG sprite sheet into this many equal cells. Animation only |
An image or animation element that declares none of x, y, width,
height or fit fills the whole frame. Declaring any one of them places and
sizes it instead; an unset width or height then falls back to the display’s
own width or height, never to the asset’s native size.
fps, loop and sheet_frames are animation-only — setting one on any other
kind is a load error. Omit sheet_frames when the source is an animated GIF.
An image or animation element cannot set length or align.
layer "art" {
element "animation" "flames" {
source = "flames.png"
sheet_frames = 12
fps = 24
loop = true
x = 0
y = 0
width = 64
height = 32
fit = "cover"
}
}Starting a show from a handler
Two event handler action verbs drive a show. Both take the show name as their label — not a device, since a show already names its own default display:
event_handler "start_attract" {
when = "game.attract_started"
actions {
display_show "attract" {
device = device.display.dmd.main_display
priority = 20
blend = "over"
opacity = 0.8
}
}
}
event_handler "end_attract" {
when = "game.started"
actions {
stop_display_show "attract" {}
}
}| Verb | Attribute | Description |
|---|---|---|
display_show | show | Show name, if not given as the block label |
display_show | device | Target display, overriding the show’s own device |
display_show | key | Stack slot name, so a later call replaces this instance |
display_show | priority | Overrides the show’s declared priority |
display_show | blend | Overrides the show’s declared blend |
display_show | opacity | Overrides the show’s declared opacity |
stop_display_show | show | Show name, if not given as the block label |
stop_display_show | key | Stop the instance occupying this stack slot |
Play-time wins: whatever the verb names overrides what the show declared.
Display devices
A show needs a display to play on. Declare one as a
device with the display category:
device "display" "dmd" "main_display" {
id = 30
refresh_rate = 60
shades = 16
dimensions {
width = 128
height = 32
}
}A dmd display must declare dimensions — geometry is never invented on
the author’s behalf. See
Display devices
for the full attribute list.
Example
An attract-mode banner over artwork, with a live score readout that only appears once the game starts:
device "display" "dmd" "main_display" {
id = 30
dimensions {
width = 128
height = 32
}
}
display_show "attract_loop" {
duration = 6000
loop = true
device = "main_display"
priority = 10
trigger {
when = "system.attract_started"
}
stop_when = ["game.started"]
layer "art" {
element "image" "backdrop" {
source = "attract-bg.png"
}
}
layer "titles" {
priority = 10
blend = "max"
element "text" "title" {
text = "RAMP RUNNER"
x = 12
y = 4
at = 0
for = 3000
}
element "text" "invite" {
text = "PRESS START"
x = 16
y = 4
at = 3000
for = 3000
}
}
}
display_show "score_readout" {
duration = 1000
loop = true
device = "main_display"
priority = 20
trigger {
when = "game.started"
}
layer "hud" {
element "value" "score" {
text = "${score}"
x = 2
y = 20
length = 9
align = "right"
}
}
}Device
Device blocks declare and configure physical hardware in a pinball machine. They centralize hardware-specific settings such as IDs, debounce times, electrical characteristics, and behavioral properties, separating hardware configuration from game logic.
Syntax
A device block takes three labels: category, type, and name. The category determines the hardware class, type specifies the behavior variant, and name provides a unique identifier used in event generation and references.
device "switch" "bumper" "pop_bumper_1" {
id = 0x40
tags = ["pop_bumper", "playfield"]
description = "Upper left pop bumper"
debounce {
activate_ms = 2
release_ms = 2
}
}Device Categories
| Category | Description | Example Types |
|---|---|---|
switch | All switch types | slingshot, opto, spinner, target, drop, standup, rollover, bumper, button, gate, trough, mechanical |
coil | All coil/solenoid types | standard, flipper, diverter, motor, bumper |
flipper | Flipper assemblies declared as one unit | standard |
light | All light types | led, rgb, gi_string, flasher |
display | All display types | dmd, lcd, segment |
motor | Stepper and servo motors | stepper, servo |
sensor | Advanced sensors | accelerometer, ir |
Switch subtypes target, drop, and standup are all distinct. Use target for generic hit-only targets that do not need the drop/standup semantics below; use drop for targets that fall and must be reset with a coil; use standup for upright hit-only targets that are conventionally grouped into banks.
Common Properties
These properties are available on all device blocks:
| Property | Type | Default | Description |
|---|---|---|---|
id | hex/int | required | Hardware identifier |
tags | list | [] | Grouping tags for bulk references |
description | string | "" | Human-readable description |
part_number | string | "" | Replacement part number |
notes | string | "" | Service notes |
enabled | bool | true | When false, the device is declared but ignored by the runtime — inputs are dropped and action verbs become no-ops |
conditionis not a device attribute. A device block has no condition binding, and writing one — e.g.device "light" "led" "l1" { condition = var.l1_lit }— is now a load error naming the file, line, and column. It used to be silently ignored, so the device never actually gated on anything. To drive a device from game state, react to the driving event with an event handler action (set_light,flash_light, …) instead.
Debounce Settings
Switch devices support debounce configuration to filter electrical noise:
debounce {
activate_ms = 2 # Minimum time before registering activation
release_ms = 2 # Minimum time before registering release
type = "active_low" # active_low, active_high, both_edges
sample_count = 3 # Samples required for state change
}| Property | Type | Default | Description |
|---|---|---|---|
activate_ms | int | 2 | Milliseconds stable before activation |
release_ms | int | 2 | Milliseconds stable before release |
type | string | "active_low" | Edge detection type |
sample_count | int | 3 | Required consecutive samples |
Automatic Event Generation
Device blocks automatically generate events based on the device type. Events follow the pattern device.<name>.<action>:
| Category | Type | Generated Events |
|---|---|---|
| switch | spinner | device.<name>.spin |
| switch | target | device.<name>.hit |
| switch | rollover | device.<name>.rollover |
| switch | loop | device.<name>.cleared |
| switch | ramp | device.<name>.cleared |
| switch | orbit | device.<name>.cleared |
| switch | slingshot | device.<name>.hit |
| switch | bumper | device.<name>.hit |
| switch | button | device.<name>.hit |
| switch | opto | device.<name>.cleared |
| switch | gate | device.<name>.cleared |
| switch | trough | device.<name>.active, device.<name>.cleared |
| switch | (any other) | device.<name>.activated |
Coils, lights, and flippers generate no action event of their own — they are commanded, not read.
All switch devices also generate device.<name>.active and device.<name>.inactive events for raw state changes.
Event Aliases
Event aliases give mechanical devices the vocabulary their behavior calls for, without multiplying low-level names. Aliases are emitted on top of the raw active / inactive events — handlers can subscribe to whichever name reads most naturally.
| Alias | Emitted by | Meaning |
|---|---|---|
rested | switch drop | Target has settled in the up position after a reset pulse completes |
state_changed | any device with explicit state | The device’s declared state has transitioned (e.g., drop-target FSM) |
moved | motor stepper, motor servo | The device has begun moving toward a new target position |
reached | motor stepper, motor servo | The device has arrived at its commanded position |
moving | motor stepper, motor servo | The device is currently in motion (emitted continuously per tick) |
Aliases carry the same event payload as the underlying hardware event, plus a state field whose value matches the device’s declared state or position name.
Drop Targets
A switch drop declares a drop target that falls when hit and must be reset with a coil. Drop targets implement an implicit state machine — up → hit → resetting → up — and emit both the per-state aliases above and rested once the reset pulse completes.
device "switch" "drop" "drop_center" {
id = 0x51
tags = ["drop_bank", "center"]
reset {
coil = device.drop_reset_coil
pulse_ms = 30
debounce_ms = 50
}
}| Sub-block | Field | Type | Default | Description |
|---|---|---|---|---|
reset | coil | device | required | Coil device that physically resets the target |
reset | pulse_ms | int | 30 | Coil pulse duration when firing the reset |
reset | debounce_ms | int | 50 | Quiet window after reset before the target is considered rested |
reset | auto_ms | int | 0 | Delay between the bank completing and the runtime firing the reset stroke itself |
auto_ms = 0 is a meaningful setting, not an unset one: it means the engine
never resets the bank on its own and your rules own the decision entirely, so a
rule can hold a bank down indefinitely — lock-lit-until-collected, or a bank
that stays sunk for the length of a mode. The countdown runs on the engine tick
rather than a wall clock, so the stroke lands at a reproducible point in the
event stream, and its resolution is the tick interval.
Events emitted: device.<name>.hit, device.<name>.state_changed, device.<name>.rested, plus the raw active/inactive.
Stand-up Targets
A switch standup declares an upright hit-only target. Unlike drop targets, stand-ups have no reset semantics — they stay upright after being struck.
device "switch" "standup" "standup_1" {
id = 0x61
tags = ["standup_bank"]
}Events emitted: device.<name>.hit plus the raw active/inactive. There is no rested or reset {} sub-block.
Stand-ups are conventionally grouped with a shared tag so a single handler can score all targets in the bank:
score "event" "standup_hit" {
when = tag.standup_bank.hit
points = 500
}Servo Positions
Servo motors (motor servo) support a positions {} block that assigns symbolic names to target positions. This keeps game logic driver-agnostic — the .cade file refers to named positions, and the platform driver maps those to the physical pulse width or step count.
device "motor" "servo" "left_diverter" {
id = 0x80
tags = ["diverter"]
positions {
home = 0
up = 90
down = -45
}
}| Field | Type | Description |
|---|---|---|
home | number | Conventional rest/parked position (not required, but widely used as the default start) |
| any | number | Additional named positions (driver-agnostic scalar values) |
Events emitted: device.<name>.moved, device.<name>.moving, device.<name>.reached, and device.<name>.state_changed. The state field on each event carries the position name.
Display Devices
A display device declares a panel that
display shows render onto. The
subtype is the second label: dmd and lcd are pixel-addressed surfaces,
segment and alphanumeric are not.
device "display" "dmd" "main_display" {
id = 30
refresh_rate = 60
shades = 16
dimensions {
width = 128
height = 32
}
}dimensions
| Field | Type | Default | Description |
|---|---|---|---|
width | int | required | Pixel columns |
height | int | required | Pixel rows |
A dmd display must declare a dimensions block — geometry is never
defaulted, because a browser harness sizes its canvas from this descriptor and
length-checks every frame against it, so an invented 128x32 would surface much
later as a frame-size mismatch. It is optional but meaningful on lcd.
Writing dimensions on a non-display device, or on a display subtype other than
dmd or lcd, is a load error rather than a silent no-op.
Display attributes
| Property | Type | Default | Applies to | Description |
|---|---|---|---|---|
refresh_rate | int | 60 | dmd | Target frame rate in Hz |
shades | int | 16 | dmd | Grayscale depth (16 = 4-bit) |
Both must be positive integers. They are meaningful for dmd only; another
subtype’s plain attributes fall through to the generic property passthrough.
Behavior
The behavior {} sub-block declares hardware-level reflexes the device should perform without round-tripping through the scoring engine. Behaviors are the declarative analog of a light show: you describe the input, the action, and any guards, and the config compiler lowers them to autofire-style rules the platform driver can run locally.
The block takes no label; on and do are both required. do names an action
verb in call form, where self refers to the device the behavior is attached to.
device "switch" "bumper" "pop_bumper_1" {
id = 0x40
behavior {
on = "device.pop_bumper_1.active"
do = "pulse_coil(self)"
}
}| Property | Type | Required | Description |
|---|---|---|---|
on | string | Yes | Event reference or runtime expression that triggers the action |
do | string | Yes | Action verb in call form, e.g. pulse_coil(self) or move_servo(self, position="up") |
Behaviors are compiled at config-load time and never evaluated per-tick in the scoring engine. Because of this, they preserve the standard performance budgets (50–100 Hz event tick, <200 ns runtime binding resolve, <1 ms scoring) and remain responsive even when the scoring engine is busy.
Action Verbs
Action verbs are declarative commands used by both behavior {} blocks and event handlers. They describe what to do in driver-agnostic terms — the platform driver chooses the physical implementation.
| Verb | Targets | Purpose |
|---|---|---|
pulse_coil | coil device | Fire a coil for a bounded duration |
move_servo | motor servo device | Command a servo to a named positions {} entry |
set_state | any stateful device | Transition a device’s declared state (e.g., drop-target FSM) |
trigger_behavior | any device with behavior {} named blocks | Fire a named behavior on demand |
pulse_coil
pulse_coil "drop_reset_coil" {
duration = "30ms"
}The coil comes from the block label. duration is a duration string; power
(an integer) is also accepted.
move_servo
move_servo {
device = device.left_diverter
position = "up"
}The position value must be a key declared in the target servo’s positions {}
block. Alternatively, give angle an integer to command a raw position with no
positions {} entry:
move_servo {
device = device.left_diverter
angle = 45
}Exactly one of position or angle is required — supplying both, or neither,
is a config error.
set_state
set_state {
device = device.drop_center
position = "up"
}trigger_behavior
trigger_behavior {
device = device.pop_bumper_1
behavior = "auto_fire_coil"
}Device References
Devices are referenced using the device.<name> syntax in scoring rules, event handlers, and other configuration:
score "event" "spinner_points" {
when = device.center_spinner.spin
points = 100
}Tag-based references match all devices with a given tag:
event "switch" {
device = tag.slingshot
}Device Settings
Certain device types support a settings block for physics-abstract parameters. These values are driver-independent — each platform driver interprets them according to its own physics model.
Flipper Settings
device "flipper" "standard" "left_flipper" {
id = 1
hardware {
coil = "C01"
switch = "S01"
}
settings {
strength = 75
hold_time = "250ms"
}
}| Setting | Type | Description |
|---|---|---|
strength | int | Flipper kick strength (physics-abstract) |
hold_time | duration | How long the flipper holds in the up position |
Kicker Settings
Kicker devices support kick physics parameters in their settings block. These values are pushed to the platform driver during registration and consulted when a kick_ball action references the kicker by name.
device "switch" "kicker" "Kicker1" {
id = 41
settings {
kick_angle = 190
kick_strength = 10
}
}| Setting | Type | Description |
|---|---|---|
kick_angle | int | Kick angle in degrees (physics-abstract) |
kick_strength | int | Kick strength (physics-abstract, 0 = device default) |
The VPX driver maps these to Kicker::Kick(angle, speed, inclination) parameters. A physical machine driver would map them to coil pulse parameters. The kick_ball action block itself carries only the device name, keeping game logic driver-agnostic.
Device Audio
A device makes sound through two top-level blocks that name it as their
owner: a route{} listing the candidate sounds, and a selection{} deciding
which one plays. A nested audio {} block under a device no longer parses —
it is a hard error, not a silent no-op.
device "switch" "bumper" "pop_bumper_1" {
id = 12
}
route {
owners = device.pop_bumper_1
source "audio_clip" "bumper_normal" { weight = 3 }
source "audio_clip" "bumper_good" { weight = 2 }
source "audio_clip" "bumper_great" { weight = 1 }
}
selection {
owners = device.pop_bumper_1
selection_method = "weighted"
}A single-clip device needs only the route; selection defaults to random,
which is indistinguishable from fixed when there is one candidate. A source
may also be a synth voice rather than a recorded clip:
route {
owners = device.pop_bumper_1
source "synth" "chime" {
weight = 1
pitch = 880
velocity = 1.0
}
}See Routing and selection for the full attribute list.
Examples
Pop Bumper Assembly
device "switch" "bumper" "pop_bumper_1" {
id = 0x40
debounce {
activate_ms = 2
}
tags = ["pop_bumper", "playfield"]
}
device "coil" "bumper" "pop_bumper_1_coil" {
id = 0x30
pulse_ms = 20
recycle_ms = 100
tags = ["pop_bumper"]
}
device "light" "led" "pop_bumper_1_light" {
id = 0x90
color = "red"
tags = ["pop_bumper"]
}Flipper Assembly
device "switch" "button" "left_flipper_button" {
id = 0x01
debounce {
activate_ms = 0
release_ms = 0
}
tags = ["flipper", "cabinet"]
}
device "coil" "flipper" "left_flipper_main" {
id = 0x00
pulse_ms = 30
eos_switch = device.left_flipper_eos
tags = ["flipper"]
}
device "switch" "eos" "left_flipper_eos" {
id = 0x02
normally_closed = true
tags = ["flipper", "eos"]
}Opto Ball Trough
device "switch" "opto" "trough_1" {
id = 0x60
invert = true
debounce {
activate_ms = 20
release_ms = 20
}
tags = ["trough", "ball_detection"]
}
device "switch" "opto" "trough_2" {
id = 0x61
invert = true
debounce {
activate_ms = 20
release_ms = 20
}
tags = ["trough", "ball_detection"]
}
device "coil" "standard" "trough_eject" {
id = 0x40
pulse_ms = 25
recycle_ms = 500
tags = ["trough"]
}Drop Target Bank
device "coil" "standard" "drop_reset" {
id = 0x32
pulse_ms = 30
recycle_ms = 250
tags = ["drop_bank"]
}
device "switch" "drop" "drop_left" {
id = 0x50
tags = ["drop_bank"]
reset {
coil = device.drop_reset
pulse_ms = 30
debounce_ms = 50
}
}
device "switch" "drop" "drop_center" {
id = 0x51
tags = ["drop_bank"]
reset {
coil = device.drop_reset
pulse_ms = 30
debounce_ms = 50
}
}
device "switch" "drop" "drop_right" {
id = 0x52
tags = ["drop_bank"]
reset {
coil = device.drop_reset
pulse_ms = 30
debounce_ms = 50
}
}
score "event" "drop_hit" {
when = tag.drop_bank.hit
points = 1000
}
score "event" "bank_cleared_bonus" {
when = tag.drop_bank.rested
points = 5000
}Stand-up Target Array
device "switch" "standup" "standup_1" {
id = 0x61
tags = ["standup_bank"]
}
device "switch" "standup" "standup_2" {
id = 0x62
tags = ["standup_bank"]
}
device "switch" "standup" "standup_3" {
id = 0x63
tags = ["standup_bank"]
}
device "switch" "standup" "standup_4" {
id = 0x64
tags = ["standup_bank"]
}
device "switch" "standup" "standup_5" {
id = 0x65
tags = ["standup_bank"]
}
score "event" "standup_award" {
when = tag.standup_bank.hit
points = 500
}Servo-Driven Diverter
device "motor" "servo" "left_diverter" {
id = 0x80
tags = ["diverter"]
positions {
home = 0
up = 90
down = -45
}
}
event_handler "arm_diverter" {
when = signal.ramp_armed
actions {
move_servo {
device = device.left_diverter
position = "up"
}
}
}
event_handler "park_diverter" {
when = device.left_diverter.reached
condition = event.state == "up"
actions {
move_servo {
device = device.left_diverter
position = "home"
}
}
}Platform
Platform blocks configure hardware drivers that bridge Cade to physical or virtual pinball hardware. Each platform driver is declared with a type and an instance name. Multiple instances of the same driver type can coexist with different configurations.
Platform blocks live in your cade.conf application config — not in .cade table files. Cade looks for cade.conf in the current directory, then $XDG_CONFIG_HOME/cade/cade.conf (usually ~/.config/cade/cade.conf); override the location with --config.
Syntax
A platform block takes two labels: the driver type and an instance name. Cade includes three built-in driver types — fast (FAST Pinball serial hardware), grpc (network bridge for external systems like Visual Pinball), and virtual (software simulation for development and testing). Any other type label fails at startup.
platform "fast" "main" {
net_port = "/dev/ttyUSB0"
baud = 921600
watchdog_ms = 1000
}Instance names must be unique across all platform blocks, regardless of driver type — platform "virtual" "dev" and platform "fast" "dev" collide.
Attribute names are not checked when the file is read: a misspelled driver attribute is silently ignored rather than reported, and surfaces later as a missing-value error (or as the default quietly taking effect). Values must be strings, numbers, or booleans — a list-valued attribute anywhere in a platform block aborts config loading.
Common Properties
These apply to every driver type:
| Property | Type | Default | Description |
|---|---|---|---|
connect_timeout | string | "10s" | Duration to wait for the initial connection |
command_timeout | string | "100ms" | Duration to wait for a command to be acknowledged |
reconnect_interval | string | "5s" | Duration between reconnection attempts |
debug_logging | bool | false | Log driver traffic |
debug_log | string | "" | Path to write the driver debug log |
The three duration values are duration strings ("10s", "250ms", "1m"); an unparseable value is a config error.
FAST Pinball
The FAST driver communicates with FAST Pinball controller boards over serial ports.
platform "fast" "main" {
net_port = "/dev/ttyUSB0"
exp_port = "/dev/ttyUSB1"
baud = 921600
platform = "2000"
watchdog_ms = 1000
}Properties
| Property | Type | Default | Required | Description |
|---|---|---|---|---|
net_port | string | (none) | yes | Serial port for the NET processor (the only required attribute in a platform block) |
exp_port | string | "" | no | Serial port for the EXP processor |
baud | int | 921600 | no | Serial baud rate (must be positive) |
platform | string | "2000" | no | FAST platform code ("2000" for Neuron) |
watchdog_ms | int | 1000 | no | Watchdog timeout in milliseconds (non-negative) |
gRPC
The gRPC driver exposes a gRPC server that external systems (such as Visual Pinball X) connect to for bidirectional communication.
platform "grpc" "vpx_bridge" {
port = 50051
enable_gateway = true
enable_reflection = true
enable_tls = false
enable_cors = true
}Properties
| Property | Type | Default | Required | Description |
|---|---|---|---|---|
port | int | 50051 | no | gRPC server listen port (1-65535) |
platform_address | string | "" | no | Address of an external platform service to connect out to (e.g. "localhost:50052"); empty leaves the driver in server-only mode |
connect_timeout_sec | int | 10 | no | Timeout in seconds for the initial connection to platform_address (must be at least 1) |
enable_gateway | bool | true | no | Enable the gRPC-gateway REST proxy |
enable_reflection | bool | true | no | Enable gRPC server reflection |
enable_tls | bool | false | no | Enable TLS encryption |
cert_file | string | "" | no | Path to the TLS certificate file |
key_file | string | "" | no | Path to the TLS private key file |
enable_cors | bool | true | no | Enable CORS headers on the gateway |
When enable_cors is enabled the gateway permits all origins; the allowed origin list is not configurable.
Setting enable_tls = true without both cert_file and key_file is not caught when the config is read — the driver fails when it starts. Always set all three together.
Virtual
The Virtual driver simulates pinball hardware in software. It is used for development, testing, and running table configurations without physical hardware.
platform "virtual" "dev" {
switch_count = 64
coil_count = 32
light_count = 128
servo_count = 8
}Properties
| Property | Type | Default | Required | Description |
|---|---|---|---|---|
switch_count | int | 64 | no | Number of virtual switches |
coil_count | int | 32 | no | Number of virtual coils |
light_count | int | 128 | no | Number of virtual lights |
servo_count | int | 8 | no | Number of virtual servos |
All count values must be non-negative.
Examples
Visual Pinball Development Setup
platform "grpc" "vpx_bridge" {
port = 50051
enable_reflection = true
enable_gateway = true
enable_cors = true
}FAST Hardware with Dual Processors
platform "fast" "main" {
net_port = "/dev/ttyUSB0"
exp_port = "/dev/ttyUSB1"
baud = 921600
platform = "2000"
watchdog_ms = 1000
}Headless Testing
platform "virtual" "test" {
switch_count = 128
coil_count = 64
light_count = 256
servo_count = 16
}Noise Context
A noise_context block declares a named, seedable source of parametric noise — an algorithm, a seed, and a cache size, recorded under a name.
Note: named contexts are declared and validated today, but expressions cannot yet select one. The noise functions (noise, noise_range, perlin, simplex, hash_noise) all draw from the table’s single built-in noise stream, seeded from the game. Declare contexts to describe the streams your table wants; the per-context binding is not wired up yet.
Declaration Syntax
noise_context "<name>" {
algorithm = "simplex" # "perlin", "simplex", "hash", "white"
seed_base = 12345 # integer seed, or "time" / "random" / "deterministic"
quality = "balanced" # advisory hint
cache_size = 256 # number of recent noise values to cache
}A noise_context block takes exactly one label — the context name. Every attribute is optional, and no other attributes are accepted.
| Attribute | Type | Description |
|---|---|---|
| name | string | Context name (the block label) |
algorithm | string | Noise algorithm: "perlin", "simplex", "hash", or "white" |
seed_base | integer/string | Fixed integer seed, or "deterministic", "time", or "random" |
quality | string | Advisory quality hint: "fast", "balanced", "high_quality" |
cache_size | integer | Number of recent noise values to cache (defaults to 256) |
Only the four algorithm names above are accepted — any other value is a configuration error. quality, by contrast, is not validated and does not change output: each algorithm’s own characteristics determine the quality, so treat quality as documentation of intent. seed_base is not validated either, so check the spelling of "deterministic", "time", and "random" yourself.
Algorithms
Four algorithms are available, trading coherence against speed:
| Algorithm | Character | Best for |
|---|---|---|
perlin | Smooth, coherent values with natural variation | High-quality smooth transitions |
simplex | Coherent like Perlin with better performance | Balanced general-purpose noise |
hash | Good distribution, less coherence, very fast | High-frequency sampling (e.g. probability) |
white | Pure uncorrelated randomness | random()-style draws with no smoothing |
Seed Base
Set seed_base to a fixed integer to pin a context to a reproducible sequence — the same seed always produces the same values. Use "deterministic" for a fixed zero seed, or "time" / "random" when you want fresh randomness on every run. Leaving seed_base unset behaves the same as "random".
# Deterministic context — identical results across runs
noise_context "scoring" {
algorithm = "simplex"
seed_base = 12345
quality = "balanced"
}
# Time-seeded context — true randomness each run
noise_context "expression_random" {
algorithm = "white"
seed_base = "time"
quality = "fast"
}Multiple Contexts
Declare a context per system — scoring variation, probability sampling, animation timing — so each one’s intended algorithm and seed are recorded in one place. Give each its own seed_base so the streams will stay distinct once per-context selection lands.
noise_context "scoring" {
algorithm = "simplex"
seed_base = 12345
quality = "balanced"
}
noise_context "probability_variables" {
algorithm = "hash" # fast for probability sampling
seed_base = 54321
quality = "fast"
}Noise in Expressions
Noise functions are available in any expression field. All arguments and results are integers:
| Function | Description |
|---|---|
noise(x, y, z) | Coherent noise at a 3D coordinate |
noise_range(x, y, z, min, max) | Coherent noise mapped into min-max (min <= max) |
perlin(x, y, z) | Perlin noise at a 3D coordinate |
simplex(x, y, z) | Simplex noise at a 3D coordinate |
hash_noise(x, y, z) | Fast hash noise at a 3D coordinate |
Pass 0 for the axes you do not need — a one-dimensional sweep over game time is perlin(var.game_time, 0, 0).
Examples
Scoring Variation
Adding natural variation to a point value with noise:
noise_context "scoring" {
algorithm = "simplex"
seed_base = 12345
quality = "balanced"
}
variable "int" "bumper_base_value" {
initial = 1000
scope = "player"
}
score "event" "bumper_hit" {
when = device.bumper.hit
# Add 0-20% variation to the base bumper value
points = var.bumper_base_value + noise_range(var.ball_time, var.ball_number, 0, 0, var.bumper_base_value / 5)
}Animation
Perlin noise gives a smooth, slowly drifting value suitable for visual effects:
noise_context "animation" {
algorithm = "perlin" # smooth, coherent motion
seed_base = 98765
quality = "high_quality"
}
variable "int" "ambient_glow" {
initial = 0
scope = "global"
# Smoothly varying glow value driven by Perlin noise over game time
formula = perlin(var.game_time, 0, 0) % 1000
}Assembly
An assembly is a reusable, parameterized template for a group of blocks — devices,
variables, scoring rules, and more — that you stamp out once per flipper, target,
or lane. You define the repeated structure once with assembly, mark the parts
that differ as parameters, and instantiate it with a use block.
This page is the block reference. For a step-by-step walkthrough of refactoring a duplicated config into an assembly, see Reusing Config with Assemblies.
assembly "standup" {
parameter "int" "switch_id" { required = true }
parameter "int" "lamp_id" { required = true }
parameter "int" "points" { default = 5000 }
device "switch" "standard" "sw" { id = param.switch_id }
device "light" "standard" "lamp" { id = param.lamp_id }
score "event" "scored" {
when = device.self.sw.hit
points = param.points
}
}
use "standup" "left" {
switch_id = 0x30
lamp_id = 0x50
points = 5000
}
use "standup" "right" {
switch_id = 0x31
lamp_id = 0x51
points = 10000
}Defining an assembly
An assembly block takes one label — its name:
assembly "<name>" {
# parameters, then the blocks to template
}Assembly attributes
| Property | Type | Description |
|---|---|---|
description | string | Human-readable note describing the assembly |
tags | list of strings | Tags applied to every device created by every instance (merged additively with instance and device tags) |
Body
Inside the body you write the blocks the assembly produces, exactly as you would at the top level:
| Block | Notes |
|---|---|
device | device "<category>" "<type>" "<name>" |
variable | variable "<type>" "<name>" |
constant | constant "<type>" "<name>" |
score | score "<type>" "<name>" — event, modifier, or accumulator |
signal | signal "<type>" "<name>" |
event_handler | event_handler "<name>" |
audio_clip | audio_clip "<name>" |
route | Unlabeled; addresses its owner with owners = device.self.<name> |
selection | Unlabeled; same owners = device.self.<name> addressing |
use | A nested instantiation of another assembly |
Reference a parameter with param.<name>, and refer to the assembly’s own blocks
with the self prefix (for example when = device.self.sw.hit, or
condition = var.self.lit == false).
route and selection carry no block label — like their top-level
counterparts, they name what they belong to through owners, which inside an
assembly points at the instance’s own device:
assembly "pop_bumper" {
parameter "int" "switch_id" { required = true }
device "switch" "standard" "sw" { id = param.switch_id }
route {
owners = [device.self.sw]
source "synth" "pop_voice" {
weight = 1
velocity = 1.0
}
}
}Each instance gets its own copy of the route, wired to that instance’s device.
Inside an assembly every owner reference must take the <type>.self.<name> form
and name a block the assembly itself declares — an assembly cannot own another
instance’s blocks. The valid owner types are device, score, modifier,
accumulator, and event_handler. See
Synth for the full route block.
Nested use blocks may go up to ten levels deep.
Parameters
A parameter block declares one input to the assembly. It takes two labels — the
value type and the name:
parameter "<type>" "<name>" {
required = true
}| Property | Type | Description |
|---|---|---|
required | bool | When true, every use must supply this parameter (default false) |
default | (varies) | Value used when a use omits the parameter |
description | string | Human-readable note describing the parameter |
validation | string | Validation rule for the supplied value |
The first label is the parameter’s type. Cade accepts:
| Type | Accepts |
|---|---|
string | A quoted string |
int | A whole number (a fractional value is rejected) |
float | Any number |
bool | true or false |
list | A list value |
map | A map value |
Any other type name is a configuration error.
A parameter is optional when it has a default and required when required = true.
Instantiating with use
A use block stamps out one instance of an assembly. It takes two labels — the
assembly type (its name) and a unique instance name:
use "<assembly>" "<instance>" {
# parameter arguments, plus optional generators
}Supply each parameter as an attribute. Every attribute that is not one of the three reserved meta-arguments below must name a parameter the assembly declares — a typo’d or stray argument is a configuration error, not a silent no-op. The reserved meta-arguments are:
| Meta-argument | Type | Description |
|---|---|---|
count | int | Stamp out N instances; the index is appended to each instance name |
for_each | list or map | Stamp out one instance per element |
tags | list of strings | Tags applied to every device of this instance |
Naming
Each instance’s blocks are prefixed with the instance name and a double underscore.
Given the example above, use "standup" "left" produces devices named
left__sw and left__lamp. Reference an instance’s generated block from outside
the assembly with device.<instance>__<block> — for example device.left__sw.
You can also write the logical form device.assembly.<instance>.<block>, which
Cade rewrites to the expanded <instance>__<block> name during expansion. Both
refer to the same generated device:
signal "combo" "double_flip" {
switches = [
device.assembly.left_flipper.button,
device.assembly.right_flipper.button,
]
time_window = "200ms"
points = 500
}Examples
A flipper assembly
assembly "flipper" {
parameter "int" "button_id" { required = true }
parameter "int" "coil_id" { required = true }
parameter "string" "position" { required = true }
parameter "int" "pulse_ms" { default = 30 }
device "switch" "standard" "button" {
id = param.button_id
}
device "coil" "standard" "power" {
id = param.coil_id
pulse_ms = param.pulse_ms
}
}
use "flipper" "left_flipper" {
button_id = 0x11
coil_id = 0x01
position = "left"
}
use "flipper" "right_flipper" {
button_id = 0x12
coil_id = 0x02
position = "right"
}Generating instances with count
use "trough_switch" "trough" {
count = 4
}This produces the instances trough__0, trough__1, trough__2, and
trough__3 — the index is joined with the same double underscore used for block
names, so a switch named sw inside the assembly becomes trough__0__sw.
Generating instances with for_each
for_each takes a list or a map and stamps out one instance per element, named
<instance>__<key>. With a list, each element is its own key:
use "standup" "bank" {
for_each = ["left", "center", "right"]
switch_id = 0x30
}That produces bank__left, bank__center, and bank__right. With a map, the
keys name the instances and each value’s entries are merged in as per-instance
arguments, overriding anything set on the use block itself:
use "standup" "bank" {
for_each = {
left = { switch_id = 0x30, points = 5000 }
center = { switch_id = 0x31, points = 7500 }
right = { switch_id = 0x32, points = 5000 }
}
}Because the per-iteration values are merged before validation, a map value can
supply a parameter marked required = true. Instances expand in sorted key
order, so the result is deterministic.
Module
A module bundles a game feature — its mode behavior, scoring, variables, event
handling, audio, and light shows — into one self-contained, composable unit.
Modules are how you build modes: there is no standalone top-level mode block, a
module wraps one.
module "multiball" {
description = "3-ball multiball with a rescue jackpot"
mode {
priority = 500
on_start {
do = <<-EOT
set var.balls_in_play = 3
emit multiball_started
EOT
}
}
# Coil pulses and other hardware actions live in a module-level event handler,
# not in the mode body. No condition is needed — a module-scoped handler only
# runs while the module's mode is active.
event_handler "eject_balls" {
when = multiball_started
actions {
pulse_coil "trough_eject" { duration = "50ms" }
}
}
variable "int" "balls_in_play" {
initial = 0
scope = "game"
}
score "event" "jackpot" {
when = device.center_ramp.made
points = 100000
}
}A module that contains a mode block participates in the runtime mode stack. A
module without a mode is always-on infrastructure — use it to group variables,
scoring, and handlers that should run for the whole game.
Syntax
A module block takes one label — its name:
module "<name>" {
# attributes, a mode, and module-scoped blocks
}Module attributes
| Property | Type | Description |
|---|---|---|
description | string | Human-readable note describing the module |
suppressed_by | list of strings | Names of modules that suppress this one while they are active |
The mode block
The mode block makes the module a mode on the priority stack. It is unlabeled —
the module’s name is the mode’s name. A module may contain at most one mode.
mode {
priority = 500
stop_events = ["multiball.ended"]
active_on_startup = false
on_start { do = <<-EOT ... EOT }
on_end { do = <<-EOT ... EOT }
events { /* on "<event>" { do = ... } */ }
timers { /* <name> { duration, ... } */ }
device_control { /* target "device" { select {…} apply {…} } */ }
}A device_control block may also sit inside the mode, where it applies only
while the mode is active. At that scope a condition is redundant and ignored —
the mode being active is the condition — so write conditional device control at
module scope instead.
| Property | Type | Description |
|---|---|---|
priority | int | Stack priority; higher wins when modes overlap. Must be non-negative |
stop_events | list of strings | Events that end the mode when any of them fire |
active_on_startup | bool | When true, the mode is active from the start of a ball (default false) |
description | string | Human-readable note |
Action bodies use do
Every action block inside a mode — on_start, on_end, the on handlers in an
events block, and a timer’s on_expire / on_complete — carries its actions in
a single do heredoc attribute. The heredoc holds command-style statements:
| Statement | Effect |
|---|---|
set var.<name> = <expr> | Assign a variable |
emit <event> | Fire an event |
start_mode "<name>" | Start another mode |
end_mode | End this mode |
on_start {
do = <<-EOT
set var.balls_in_play = 3
set var.ball_save_active = true
emit multiball_started
EOT
}A mode body carries no condition and no inline branching. Hardware actions —
pulsing a coil, flashing a light — and any reaction that needs a condition belong
in a module-level event_handler (see Module-scoped blocks),
gated on the mode being active.
Lifecycle blocks
| Block | Runs |
|---|---|
on_start | Once when the mode activates |
on_end | Once when the mode deactivates |
mode {
priority = 500
on_start {
do = <<-EOT
set var.ball_save_active = true
emit multiball_started
EOT
}
on_end {
do = <<-EOT
set var.ball_save_active = false
set var.balls_in_play = 1
EOT
}
}Scoped events
The events block holds on "<event>" { ... } handlers that are active only
while the mode is running. Each on handler carries a do heredoc:
events {
on "device.left_lock.activated" {
do = <<-EOT
set var.locked_balls = var.locked_balls + 1
EOT
}
on "ball_drained" {
do = <<-EOT
set var.balls_in_play = var.balls_in_play - 1
EOT
}
}Timers
The timers block declares named timers that start when the mode activates and
stop when it ends. Each timer is its own block, named by the block label:
timers {
ball_save {
duration = 15000 # milliseconds
loops = 1 # 1 = one-shot; 0 = repeat forever
on_expire {
do = <<-EOT
set var.ball_save_active = false
EOT
}
}
}| Property | Type | Description |
|---|---|---|
duration | int | Timer length in milliseconds |
loops | int | How many times it fires; 0 means repeat indefinitely (default) |
enabled | bool/string | Whether the timer auto-starts; may be an expression (default true) |
on_expire | block | do heredoc run each time the timer fires |
on_complete | block | do heredoc run when the timer finishes its last loop |
Functions
The functions block defines named action routines callable from the mode’s other
action blocks. Unlike the do action bodies above, a function body is written
inline with command-style statements — including inline if branches:
functions {
check_completion() {
if "var.objectives >= 3" {
end_mode
}
}
}Lifecycle events
Starting and ending a module fires a pair of lifecycle events, both named after the module:
| Event | Fires when |
|---|---|
mode.<module>.started | The mode is pushed onto the active stack |
mode.<module>.ended | The mode is popped off the stack |
module.<module>.started | The module starts (alongside the mode event) |
module.<module>.ended | The module ends (alongside the mode event) |
For a module named multiball, that is mode.multiball.started and
module.multiball.started. Subscribe to these from scoring rules or event
handlers.
stop_events is a separate mechanism: it lists events that end the mode when any
of them fire, and those are ordinarily events you emit (see the stacked-modes
example below, where a handler emits bonus_mode.ended to close the mode out).
Module-scoped blocks
Alongside the mode, a module can declare blocks that belong to the feature:
| Block | Notes |
|---|---|
variable | A variable scoped to the module |
constant | A constant scoped to the module |
score | A scoring rule — event, modifier, or accumulator |
event_handler | An event handler |
audio | Audio bindings (see below) |
shows | Light-show bindings (see below) |
device_control | Enable/suppress devices by tag while the module is active |
Each of mode, stacking, compose, audio, and shows may appear at most
once in a module; device_control is repeatable.
When the module has a mode, its scoring rules, modifiers, accumulators, and
event handlers are all automatically gated on the mode being active — Cade folds
module.<name>.active into each one’s condition, so you never have to write it
yourself. (A module with no mode is always-on infrastructure and is not gated.)
Writing condition = module.self.active explicitly is still allowed and is a
common house style — it makes the gating visible at the point of use. A typical
example is a scoring modifier that applies only while the module runs:
module "frenzy" {
mode { priority = 600 }
score "modifier" "frenzy_boost" {
condition = module.self.active
modify {
playfield_multiplier = 5
}
}
}Controlling devices
A device_control block enables or suppresses a group of devices selected by
tag. Each target block names what it acts on — "device", "module", or
"scoring" — select picks the members by tag, and apply sets what happens to
them:
device_control {
condition = var.tilt_warnings >= 3
target "device" {
select { tags = ["flipper", "coil"] }
apply { enabled = false }
}
}| Block / attribute | Description |
|---|---|
condition | When the control applies. Module scope only — ignored inside a mode |
target "<type>" | What to act on: device, module, or scoring |
select { tags } | List of tags selecting the members |
apply { enabled } | Whether the selected members are enabled |
apply { suppress } | Whether the selected members are suppressed |
enabled and suppress are both optional; omitting one leaves that aspect
untouched rather than defaulting it to false.
Stacking
The stacking block governs how multiple modules interact and whether a module
may run in more than one instance at a time:
stacking {
allow_multiple = true
max_instances = 2
priority_increment = 50
on_max = "queue"
requires = ["base"]
conflicts_with = ["tilt"]
replaces = ["hurry_up"]
}| Property | Type | Description |
|---|---|---|
allow_multiple | bool | Whether more than one instance may be active at once |
max_instances | int | Cap on simultaneous instances |
priority_increment | int | Priority added per stacked instance |
on_max | string | What to do when the cap is reached: "reject" (refuse to start), "queue" (start when a slot frees), or "replace_oldest" (end the oldest instance to make room) |
conflicts_with | list of strings | Modules that cannot be active alongside this one |
requires | list of strings | Modules that must be active for this one to run |
replaces | list of strings | Modules that this one ends when it starts |
Composition
The compose block builds a module from others, optionally overriding parts of
each. lifecycle = "managed" ties the composed modules’ start/stop to the parent:
compose {
lifecycle = "managed"
include "multiball" {
override {
timers {
ball_save { duration = 30000 }
}
audio {
music = "wizard_multiball_theme"
}
}
}
}An override block may adjust the included module’s mode, timers,
variables, scoring, audio, and shows.
Audio and shows
The audio block sets the music played while the mode is active and maps named
clips to events. The shows block binds light shows to lifecycle points:
audio {
music = "multiball_theme"
clips {
start = "multiball_start"
jackpot = "jackpot_fanfare"
}
}
shows {
active = "multiball_flash" # runs while the mode is active
start = "multiball_intro" # plays once on activation
end = "multiball_outro" # plays once on deactivation
}Example: stacked modes with multiplier restore
Three modules layer by priority. The highest-priority active mode’s multiplier wins; when it ends, the multiplier reverts to the next mode down — not to the default.
module "base_mode" {
description = "Base gameplay — 1× scoring multiplier"
mode {
priority = 100
active_on_startup = true
}
score "modifier" "base_multiplier" {
condition = module.self.active
modify {
playfield_multiplier = 100
}
}
}
module "bonus_mode" {
description = "Bonus round — 3× scoring multiplier"
stacking {
requires = ["base_mode"]
}
mode {
priority = 300
stop_events = ["bonus_mode.ended"]
}
score "modifier" "bonus_multiplier" {
condition = module.self.active
modify {
playfield_multiplier = 300
}
}
}
module "frenzy_mode" {
description = "Frenzy — 5× scoring multiplier"
stacking {
requires = ["base_mode"]
}
mode {
priority = 600
stop_events = ["frenzy_mode.ended"]
}
score "modifier" "frenzy_multiplier" {
condition = module.self.active
modify {
playfield_multiplier = 500
}
}
}Assets HTTP API
The asset catalog stores the models, images, audio and video a table draws on,
and exposes them over HTTP at /api/assets. This page is the contract a client
can rely on: what each route answers, how uploads deduplicate, and what the
limits are.
It is written for people building a client — the Blender bridge is one, and the harness itself is another. Everything described here is observable from outside: send the request, get the answer.
Three conventions run through the whole API and are worth reading before the route list:
- Reads are open, writes authorize.
GETandHEADneed no credential. Every write — upload, update, delete — requires one. “Open” is about credentials, not exposure: what a read answers is scoped by visibility. - Uploads are content-addressed. A blob is identified by the SHA-256 of its bytes, so a client that already knows the hash can ask whether the bytes are stored before sending them.
- Every asset has a visibility.
private,sharedorpublicdecides who a read answers for. An asset the caller may not see behaves exactly like an asset that does not exist — 404, never 403, on every route.
Authorization
A write carries its credential in the Authorization header. Which credential a
deployment accepts is a property of that deployment, and the two kinds are
mutually exclusive — a deployment runs one era or the other, never both, and
there is no fallback from one to the other:
| Era | Credential | Identity |
|---|---|---|
| Upload token | Authorization: Bearer <token> | Anonymous — no owner, no per-user allowance |
| User session | A session cookie, or Authorization: Bearer <session token> for a client with no cookie jar | A real user, with a per-user allowance |
A deployment configured with neither is read-only: every write answers 403 and no token will change that. The public production origin never runs the upload-token era at all — without a sign-in service it is read-only, full stop.
Two consequences worth designing around:
- A client cannot discover which era it is talking to by trying one and falling back. Configure the credential you were given.
- In the session era an asset has an owner and a default per-user allowance (75 live assets, a 150 MiB byte budget — a deployment can change both). In the token era there is no owner at all.
Failures are separated so a client can tell them apart: 401 means the credential was missing, malformed or wrong. 404 on a write means the credential was accepted and the asset does not exist — or belongs to someone else, which is deliberately indistinguishable.
That ordering is deliberate and it is load-bearing — see the token probe.
Visibility and sharing
Every asset carries one of three visibility tiers:
| Tier | Who a read answers for |
|---|---|
private | The owner only |
shared | The owner, plus the addresses they have invited |
public | Everyone, signed in or not |
An upload may set visibility in its metadata; PATCH changes it later. An
unrecognized value is rejected with 400, never silently coerced. The
default is private when a user session uploaded the asset. In the token era
there is no identity to scope to, so uploads there default to public — a
“private” row that every token holder could read anyway would be a label
pretending to be a security property.
Visibility is enforced in the answer, not the prose: listings silently exclude rows the caller may not see, and fetching one by id — or its bytes — answers 404. Nothing distinguishes “does not exist” from “not yours to see”.
Two edges worth knowing:
- Bytes are shared by content. A blob is readable when any asset that references it — as content or as a thumbnail — is readable by you. So byte-identical content cannot be per-user secret: if one owner publishes an asset and another privately holds the same bytes, the bytes are readable. Nothing leaks that was ever secret — whoever published them demonstrably had them — but a client should not treat blob reachability as proof of which asset it came from.
- Publications cascade. An asset can be readable even when its own tier says otherwise, because a published table references it — see Published tables. The cascade only ever widens reach while the publication is live; it never narrows it.
Sharing with specific people
The shared tier is addressed by email. Only the owner can see or change
an asset’s audience:
GET /api/assets/:id/shares the current audience
POST /api/assets/:id/shares invite one address
DELETE /api/assets/:id/shares/:email revoke one (URL-encode the address)
POST takes a JSON body of {"email": "..."} and answers 201. Inviting
your own address is refused with 400 — you already own the asset. Revoking
answers 204, or 404 when no such invitation exists.
The rules a client should design around:
- A grant only takes effect while the asset’s visibility is
shared. Inviting someone to aprivateasset records the invitation but changes no answer — the response says so — until the owner flips the tier. - The invited person reads the asset by signing in with that address, and the address must be verified by their sign-in provider. An address that has never signed in simply waits; the grant starts working on first sign-in.
- Moving an asset off
shared— toprivateorpublic— revokes the whole audience. Re-sharing later starts from an empty list; it does not resurrect the previous one.
On deployments without sharing configured, these routes answer 501.
Routes
The asset catalog:
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /api/assets | no | List assets, filtered and newest-first |
GET | /api/assets/stats | no | Catalog totals |
GET | /api/assets/version | no | Which build is answering |
HEAD | /api/assets/blob/:sha256 | no | Is this blob stored (and readable by you)? |
GET | /api/assets/blob/:sha256 | no | Fetch blob bytes (supports ranges) |
GET | /api/assets/:id | no | One asset’s metadata |
POST | /api/assets | yes | Upload |
PATCH | /api/assets/:id | yes | Rename, retag, or change visibility |
DELETE | /api/assets/:id | yes | Delete |
GET/POST | /api/assets/:id/shares | session | The asset’s audience |
DELETE | /api/assets/:id/shares/:email | session | Revoke one invitation |
Signed-in surfaces the same service hosts (session credential required; on deployments that do not offer the feature they answer 501):
| Method | Path | Purpose |
|---|---|---|
GET/PUT/DELETE | /api/assets/tables and /api/assets/tables/:id | Cloud table documents |
GET/PUT/DELETE | /api/assets/settings | The caller’s settings document |
POST/DELETE | /api/assets/tables/:id/publish | Publish / unpublish a table |
GET/POST/DELETE | /api/assets/tables/:id/shares[/:email] | A published table’s audience |
GET | /api/assets/publications | The caller’s publications |
GET | /api/assets/publications/public | Browse public tables |
GET | /api/assets/publications/shared | Tables other people have shared with you |
GET | /api/assets/publications/:id | Read one publication |
:sha256 is 64 lowercase hex characters. :id is the catalog’s own identifier,
returned by the upload — treat it as opaque. A known path hit with the wrong
method answers 405; an unknown path answers 404.
Blob responses are built for caching: the bytes behind a hash never change, so
they carry an entity tag equal to the sha and immutable cache headers. A
conditional request with If-None-Match answers 304; a single-range
Range: bytes=… request answers 206, and an unsatisfiable one 416.
Uploading
POST /api/assets is multipart/form-data only. There is no raw-body
upload; a request without a multipart body is rejected.
Three parts matter, and how you frame them decides how they are read:
| Part | Filename | Read as |
|---|---|---|
meta | must have none | A string, parsed as JSON |
file | must have one | Binary content |
thumb | must have one | Binary content — an optional preview image |
This is the single easiest thing to get wrong, and it fails in a confusing
direction: give the meta part a filename and it arrives as a file rather than
the JSON the server is looking for, so the upload is rejected for missing
metadata even though you sent it. Omit the filename on the file part and the
bytes are read as text and corrupted.
The meta part is a JSON object:
| Field | Required | Meaning |
|---|---|---|
name | yes | Display name, 1–120 characters |
kind | yes | model, image, audio or video |
sha256 | on the metadata-only path | The SHA-256 of the content, 64 lowercase hex |
tags | no | Up to 16 tags, each at most 40 characters |
visibility | no | private, shared or public — see Visibility and sharing |
width, height | no | Pixel dimensions, as a hint for browsers of the catalog |
durationMs | no | Clip length in milliseconds, same purpose |
animated | no | true when the image has more than one frame: an animated GIF, an APNG, an animated WebP |
width, height, durationMs and animated are declared, not measured:
the service stores what you send and echoes it back on every read, without
checking any of them against the bytes. A client that knows the answer should
say so; one that does not should omit the field rather than guess. animated
in particular has three states, and the third is not false: an asset that
never declared it reads back with the field absent, meaning “never
measured”, so a browsing client should not take that for “still”. Sending
anything other than true or false is rejected with 400.
The declared Content-Type of the file part is ignored. The service
identifies the bytes themselves and stores what it found; the accepted formats
are:
| Kind | Accepted |
|---|---|
image | PNG, JPEG, WebP, GIF, AVIF |
audio | WAV, MP3, OGG, FLAC, M4A |
video | MP4, WebM |
model | GLB (binary glTF), STL, OBJ |
Anything else answers 415. Bytes that are recognized but do not match the
declared kind — a PNG declared as audio — answer 400. Two model rules
worth calling out: JSON .gltf is not accepted (only the single-file binary
flavor is), and a compressed GLB (Draco or meshopt) answers 415 — the
harness never ships a decoder, so upload through the app, which decompresses on
the way in, or re-export uncompressed.
The optional thumb part is a preview image of at most 64 KB (over answers
400, as does a thumb that is not an image). It is stored content-addressed
like any blob; the asset’s metadata carries its hash, and clients fetch it
through the blob route.
A successful upload answers 201 with the new asset’s metadata, or 200
when it deduplicated onto a row that already existed — see below. If the meta
declared a sha256 alongside bytes and they disagree, the upload is rejected
with 400.
Skipping the bytes when the catalog already has them
Uploads are a two-step handshake, and the first step is optional only in the sense that skipping it costs bandwidth:
HEAD /api/assets/blob/:sha256. 200 means the bytes are already stored; 404 means they are not — or that they exist only behind assets you may not read, which for this purpose means the same thing: send the bytes.POST /api/assets. If step 1 said 200, send themetapart alone — nofilepart. Otherwise send both.
A metadata-only upload still creates a new asset row; it just does not move the
bytes again. This is what makes re-using one model under several names cheap.
The declared kind must match the stored bytes here too, or the answer is
400.
One case to handle rather than treat as impossible: a metadata-only upload can
answer 404 even though the HEAD said the blob existed. The two are separate
stores and can legitimately disagree — a delete between your two requests is
enough. The fix is to repeat the upload with the file part; you are holding
the bytes, and sending them heals the disagreement. Treating that 404 as a dead
endpoint is the wrong read.
Deduplication
The dedupe key is the pair (sha256, name), not the hash alone — and it is
scoped to your own assets. The same bytes under a different name are a
different asset; the same bytes under the same name are the same asset. In the
session era, another user uploading identical bytes under the same name gets
their own separate row — dedupe never matches across owners.
So an unchanged re-upload — same export, same name — answers 200 with the existing row and writes nothing. Clients get idempotency for free: re-sending converges instead of accumulating rows.
Tags are the exception to “writes nothing”: a dedupe merges the tags you sent into the matched row, as a union. Order is preserved, duplicates collapse, and tags are only ever added — an upload can never clear a tag another client set. Sending tags the row already has is still write-free.
That merge is what lets an asset acquire a tag after the fact: upload a model untagged, then re-send it under the same name carrying a tag, and the existing row becomes findable by that tag.
Two boundary answers: a merge that would push the row past the 16-tag cap is refused with 400 and writes nothing. And when several clients hammer the same row with conflicting tag merges at once, a request can answer 409 after repeatedly losing the race — the merge is safe to retry, and a retry converges.
Finding assets
GET /api/assets filters and orders:
| Parameter | Meaning |
|---|---|
kind | Exact match on model, image, audio or video |
tag | Exact tag match — no prefix or substring matching |
q | Case-insensitive substring match on the name |
mine | Only assets the caller owns — needs a session, 401 without one |
limit | Page size: default 50, maximum 100 |
cursor | Resume from a previous page — see below |
The answer is an object: {"v": 1, "items": [...]}, plus a cursor when the
page came back full. Pass that cursor back as ?cursor= to fetch the next,
older page; a page without a cursor is the last one.
Results are newest first. That ordering is part of the contract, not an
implementation detail: it is how a client resolves “the current version of this
thing” by asking for limit=1.
The listing answers within what the caller may see: everyone gets public
rows, a signed-in caller additionally gets their own and the ones shared with
their verified address. GET /api/assets/stats (catalog totals) needs no
credential.
Linking an external tool to an asset
A client that owns an asset elsewhere — a Blender object, say — needs to answer “which catalog row is the current version of my object?” across re-exports where the bytes change every time. Tags carry that identity.
The convention is a tag of the form:
blender:9f2c41ab77de0135
blender: followed by 16 lowercase hex characters. The length is not arbitrary —
a blender: prefix plus a 36-character UUID is 44 characters, over the 40
character tag limit, so a UUID cannot be used here.
Resolution is then one request:
GET /api/assets?kind=model&tag=blender:9f2c41ab77de0135&limit=1
Newest-first ordering means the answer is the current generation.
Re-sending an earlier version
This is the case that makes the rule above more than bookkeeping. Export v1, then v2, then change your mind and re-send v1 — undoing a tweak and pushing again.
The dedupe key matches v1’s original row, but answering with it would leave v2’s row newer, so the resolution query above would keep answering v2 forever, and no further upload could recover it.
So it does not. When an upload carries a link tag whose newest row is some other generation, the service mints a fresh generation for the already-stored bytes and answers 201. The revert becomes the newest row and resolves correctly. Re-sending it again after that is an ordinary write-free 200 — it converges, rather than minting a generation per send.
A client needs no special handling for this. It is worth knowing only because it explains why an upload of unchanged bytes under an unchanged name can legitimately answer 201 rather than 200.
Checking a credential without writing
PATCH /api/assets/:id authorizes before it looks the asset up. A PATCH
carrying an empty JSON object ({}) at an identifier that cannot exist is
therefore a side-effect-free credential check:
- 401 — the credential is wrong.
- 404 — the credential is good, and there is no such asset.
Send the {} body: the JSON parse sits between the two answers, so a bodyless
request gets a 400 instead of the clean split (that 400 still proves the
credential was accepted, but the empty object keeps the probe unambiguous).
Nothing is created, renamed or deleted on either branch, so this is safe to run
on a timer or at connect time. Pair it with GET /api/assets/stats, which needs
no credential, to tell “the catalog is unreachable” apart from “the catalog is
there and my credential is wrong”.
Deleting
DELETE /api/assets/:id removes the asset and answers 204. A row that is
already gone answers 404 — which a client doing unattended cleanup should
treat as success, not as an error.
One guard sits in front of the delete: when a live
publication references the asset, the delete
is refused with 409, and the response names the affected publications — the
question the owner is actually asking is “which of my shares does this break”.
Deleting anyway is ?force: the asset goes, and every publication it would
have broken is unpublished automatically rather than left serving an
incomplete table. A forced delete that unpublished something answers 200
with the list of what it unpublished; otherwise the delete answers 204 as
usual.
Blob bytes are reclaimed at delete time, and only once no remaining asset
references them. Two assets sharing one blob means deleting the first frees
nothing; deleting the second frees the bytes. A client can observe this with
HEAD /api/assets/blob/:sha256.
Cloud tables and settings
The same service stores two kinds of per-user documents for signed-in users. Both take a session credential on every route — there is no anonymous read, a signed-out caller gets 401 — and both answer 501 on deployments that do not offer them. Bodies are stored and returned verbatim: the service never parses a document, so its format can evolve client-side.
Table documents live at /api/assets/tables/:id, where the id is chosen by
the client:
GET /api/assets/tableslists the caller’s tables — metadata only, no bodies.GET /api/assets/tables/:idanswers one document, body included, or 404.PUT /api/assets/tables/:idupserts: a JSON body of{"body": "<the serialized table>", "name": "..."}(name optional). 201 when the document is new, 200 when it replaced one.DELETE /api/assets/tables/:idanswers 204, or 404 when there is nothing to remove. Deleting a table also removes its publication, if any.
A table body is capped at 512 KB (413 over), and an owner may hold at most
25 tables — a PUT that would mint one past the cap answers 409.
Re-saving an existing table never trips the count cap, so a full account can
always keep saving its current work.
The settings document is one per user, at /api/assets/settings:
GETanswers it, or 404 when nothing is stored — deliberately not an empty document, so a client can tell “nothing stored” from a failed read.PUTreplaces it:{"body": "<the serialized settings>"}. 201 first time, 200 after; capped at 64 KB (413 over).DELETEdiscards it: 204, or 404 when there was none.
Published tables
A signed-in owner can publish a table: a point-in-time frozen copy that viewers read, separate from the working document the editor keeps autosaving. The publication’s id is opaque and — the property a client can lean on — stable across re-publishes, so a share link and its audience survive an update. Re-publishing replaces the snapshot; it does not mint a new address.
POST /api/assets/tables/:id/publish freeze the current table
DELETE /api/assets/tables/:id/publish take it down
POST takes {"visibility": "shared" | "public", "assetIds": [...]} and
answers 201 with the publication’s metadata. private is not a publish
target — that is what unpublishing means, and the route says so with 400.
assetIds declares which catalog assets the table references; ids the
publisher does not own are silently dropped (the response counts what was
accepted, and how many were dropped).
The declared assets are the cascade: while the publication is live, anyone who may read it may also read those assets and their bytes, whatever each asset’s own visibility says. That is what makes a published table render for its audience without the owner flipping every referenced asset to public.
Reading and finding publications:
GET /api/assets/publications/:id— the snapshot, body included.publicanswers for anyone;sharedanswers for the owner and for signed-in viewers whose verified address holds an invitation; everything else is 404.GET /api/assets/publications— the caller’s own publications (session).GET /api/assets/publications/public— every public table, newest first, paginated with the same{"v": 1, "items": [...], "cursor"?}envelope andlimit/cursorparameters as the asset listing. Shared publications are deliberately absent here — their address is a link the owner hands out.GET /api/assets/publications/shared— the other side of that: the tables other people have shared with you, matched on your verified address (session). It is the inbox a viewer would otherwise have no way to enumerate, since a shared publication is unlisted by design. A deployment with sharing switched off answers 501 rather than an empty list, so “nobody has shared anything with you” is never confused with “this deployment cannot share”.
A published table’s audience is managed like an asset’s, addressed by the table id:
GET /api/assets/tables/:id/shares the audience (and the publication id)
POST /api/assets/tables/:id/shares invite one address
DELETE /api/assets/tables/:id/shares/:email revoke one
Inviting requires the table to be published first — before that there is
nothing for the invitation to attach to, and the route answers 409 with
exactly that advice. The email rules are the same as
asset sharing: verified
addresses, no self-invites, and unpublishing — or publishing over to public —
revokes the audience rather than leaving it armed for a later re-share.
Unpublishing answers 204 (or 404 when nothing was published). The snapshot and its cascade stop answering immediately.
Which build is answering
GET /api/assets/version needs no credential and answers:
{ "v": 1, "service": "assets", "contract": 1, "commit": "…", "builtAt": "…" }
contract is the API generation this build speaks; commit and builtAt are
stamped in at deploy time. A service running outside a deployment — a local
run, say — reports "unknown" for both rather than guessing.
Check this route, not a status code, when you want to know whether the API is
really there: a host with no service behind it can still answer 200 with the
site’s HTML fallback, so only a JSON body with "service": "assets" actually
proves you reached the Worker.
Limits
Exceeding a size limit answers 413; a malformed or over-long metadata field answers 400.
| Limit | Value |
|---|---|
| Model (GLB, STL, OBJ) | 32 MB |
| Video | 32 MB |
| Image | 8 MB |
| Audio | 8 MB |
| Thumbnail | 64 KB |
| Name length | 120 characters |
| Tags per asset | 16 |
| Tag length | 40 characters |
An upload whose declared Content-Length could not fit any kind is refused
before the body is read, so a client sending a far-oversize file gets its 413
promptly rather than after the whole transfer.
In the session era each user additionally has an allowance — a maximum number of live assets (default 75, answering 403 at the cap) and a total byte budget (default 150 MiB, answering 413). The limit is on what is live rather than on what has ever been uploaded, so the fix is to delete something. A deployment can also run a catalog-wide storage ceiling, which answers 507 when reached — that one is not yours to fix by deleting.
Table documents and the settings document have their own caps — 512 KB and 25 tables per owner, 64 KB for settings — described in their section.
Status codes
| Code | Meaning |
|---|---|
| 200 | Deduplicated onto an existing asset, an ordinary successful read, or a forced delete reporting what it unpublished |
| 201 | A new row was created — an asset, an invitation, a publication, or a first document save |
| 204 | Deleted, or revoked |
| 400 | Malformed metadata or body — bad JSON, missing name, a field over its limit, an unknown visibility, bytes not matching the declared kind, or a tag merge past the cap |
| 401 | Missing, malformed or wrong credential |
| 403 | The deployment is read-only, or the caller’s asset-count allowance is full |
| 404 | No such asset, blob, table or publication — or one the caller may not see; on a write, confirmation the credential was good |
| 405 | The path exists but not under this method |
| 409 | A delete that would break a published table, an invitation to an unpublished table, the per-owner table cap, or a tag merge that repeatedly lost a race (retry) |
| 413 | Over a size limit, or the write would exceed the caller’s byte budget |
| 415 | Unrecognized or unsupported file type — including compressed glTF |
| 416 | Unsatisfiable range on a blob fetch |
| 501 | This deployment does not offer the feature — sharing, tables, settings or publishing |
| 507 | The deployment’s catalog-wide storage ceiling is reached |