Introduction
Welcome to the CadeAPI reference. This documentation covers the HTTP and gRPC APIs exposed by the Caderuntime.
Base URL
http://localhost:3000
Authentication
The Cade API does not currently require authentication for local connections.
Configuration Example
Cade uses HCL-based declarative configuration. Here’s a flipper device with event-driven lighting:
flipper "left_flipper" {
id = 1
main_coil = 10
hold_coil = 11
activation_switch = 20
eos_switch = 21
events "on_activate" {
emit = "LEFT_FLIPPER_UP"
target {
device = "light.left_flipper_button"
action = "set"
parameters {
color = "blue"
brightness = 1.0
fade_ms = 50
}
}
}
events "on_deactivate" {
emit = "LEFT_FLIPPER_DOWN"
target {
device = "light.left_flipper_button"
action = "set"
parameters {
color = "white"
brightness = 0.3
fade_ms = 100
}
}
}
}
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.
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-4 unique entries (default: ["player1"])
}
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"]
}
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, fragments, pragmas, platform bindings, assemblies, and modules.
Top-Level Blocks
A table configuration is composed of these block types:
| Block | Purpose |
|---|---|
device | Physical hardware declarations (switches, coils, lights) |
event_type | Custom event type definitions with traits and fields |
variable | Game state variables with types, scopes, and constraints |
score | Scoring rules, modifiers, and accumulators |
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 PCM, no files) |
assembly / use | Reusable parameterized block templates and their instances |
module | Bundled game logic units (mode, variables, scoring, audio) |
File Structure
A typical table configuration follows this organization:
name = "My Pinball Machine"
version = "1.0.0"
# Pragma settings control validation and optimization
pragma {
mode = "strict"
optimization_level = 2
}
# Variables define game state
variable "int" "bumper_value" {
initial = 1000
min = 100
max = 10000
}
variable "bool" "skill_shot_active" {
initial = true
scope = "ball"
}
# Scoring rules respond to events
score "event" "bumper_hit" {
when = device.bumper.activated
points = var.bumper_value
}
# Modifiers change variables conditionally
score "modifier" "multiball_bonus" {
condition = mode.multiball.active
apply {
bumper_value = var.bumper_value * 2
}
}
# Devices declare hardware
device "switch" "bumper" "pop_bumper_1" {
id = 0x40
debounce {
activate_ms = 2
}
tags = ["pop_bumper", "playfield"]
}
# Platform driver binding
platform "grpc" "vpx_bridge" {
port = 50051
}
# Assembly: reusable template for repeated hardware groups
assembly "flipper" {
tags = ["player_controlled", "flipper"]
parameter "number" "button_id" { required = true }
parameter "number" "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 }
}
# Instantiate assemblies with use blocks
# Instance-level tags merge additively with assembly-level tags
use "flipper" "left_flipper" {
button_id = 0x11
coil_id = 0x01
tags = ["left_side"]
}
use "flipper" "right_flipper" {
button_id = 0x12
coil_id = 0x02
tags = ["right_side"]
}
# Module: bundle related game logic
module "multiball" {
description = "Multi-ball play mode"
mode {
priority = 500
}
variable "int" "balls_in_play" {
initial = 3
scope = "ball"
}
stacking {
allow_multiple = false
}
}
Practical Example
This example shows a table with progressive ramp scoring:
name = "Ramp Runner"
version = "1.0.0"
# Track ramp completions per ball
variable "int" "ramp_count" {
initial = 0
scope = "ball"
}
variable "int" "ramp_base_value" {
initial = 5000
}
# Value increases with each ramp shot
variable "int" "ramp_progressive_value" {
formula = var.ramp_base_value + (var.ramp_count * 1000)
computed = true
}
# Ramp device
device "switch" "ramp" "center_ramp" {
id = 0x15
debounce {
activate_ms = 5
release_ms = 10
}
tags = ["playfield", "ramp"]
}
# Progressive ramp scoring
score "event" "ramp_shot" {
when = device.center_ramp.cleared
points = var.ramp_progressive_value
update {
ramp_count = var.ramp_count + 1
}
}
# Reset on ball start
score "modifier" "ball_start_reset" {
when = game.ball_start
apply {
ramp_count = 0
}
}
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 configuration 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 {
parallel_processing = true
max_workers = 4
queue_size = 1000
timeout_ms = 5000
}
devices {
validation_level = "strict"
debounce_defaults = true
}
}
Global Directives
| Directive | Type | Default | Description |
|---|---|---|---|
mode | string | "normal" | Validation mode: strict, normal, or relaxed |
optimization_level | int | 1 | Performance optimization level (0-3) |
experimental | bool | false | Enable experimental features |
deprecated_ok | bool | false | Allow deprecated features without errors |
cache_expressions | bool | false | Enable expression result caching |
cache_size_mb | int | 10 | Maximum cache size in megabytes |
errors_as_warnings | list | [] | Error types to downgrade to warnings |
disable_checks | list | [] | Validation checks to skip |
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 overflow
These error types cannot be downgraded:
syntax_error,circular_reference,duplicate_definition,missing_required
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 | false | Cache variable expression results |
preallocate | bool | false | 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 | inherited | Override validation mode for scoring |
optimization_level | int | inherited | Override optimization for scoring |
parallel_evaluation | bool | false | Enable parallel scoring evaluation |
cache_size_mb | int | 10 | Scoring expression cache size |
events
| Directive | Type | Default | Description |
|---|---|---|---|
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 |
devices
| Directive | Type | Default | Description |
|---|---|---|---|
validation_level | string | "normal" | Device validation strictness |
debounce_defaults | bool | false | Apply default debounce to all switches |
Block-Level Overrides
Individual blocks can override pragma settings when needed:
pragma {
mode = "strict"
optimization_level = 2
}
# Most variables use strict mode from the global pragma
variable "int" "score" {
initial = 0
}
# This variable needs relaxed validation for an experimental feature
variable "int" "experimental_feature" {
pragma {
experimental = true
mode = "relaxed"
}
initial = 0
}
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 provide reusable configuration snippets that can be applied across devices, events, and scoring rules. The fragment system supports two types: static fragments with fixed values and dynamic fragments with parameterized, computed values.
Static Fragments
Static fragments define fixed configuration values that do not change based on context:
fragment "static" "hardware_timing" {
debounce = "5ms"
settle_time = "2ms"
max_velocity = 10000
deduplication = "50ms"
}
Static fragments are referenced by their qualified name: static.hardware_timing.
Dynamic Fragments
Dynamic fragments compute values based on parameters and runtime context:
fragment "dynamic" "countdown_timer" {
params = {
duration = "duration"
}
start_time = "${now()}"
elapsed = "${now() - start_time}"
time_remaining = "${max(0, duration - elapsed)}"
expired = "${time_remaining <= 0}"
}
Dynamic fragments accept parameters when applied, allowing the same template to produce different values in different contexts.
Applying Fragments
In Event Types
Event types reference static fragments by name in a list:
event_type "switch_hit" {
fragments = ["static.hardware_timing"]
traits = ["device", "player", "timestamped"]
required_fields {
device_id = "string"
state = "string"
}
}
In Scoring Rules
Scoring rules reference dynamic fragments with parameter bindings:
score "event" "timed_mode" {
fragments = [{
name = "dynamic.countdown_timer"
duration = "30s"
}]
when = "mode.bonus.collect"
points = "${1000 * time_remaining / 1000}"
}
Fragment Composition
Fragments can build on other fragments to create layered configurations:
fragment "static" "mode_timing" {
fragments = ["static.base_timing"]
mode_start_delay = "500ms"
mode_timeout = "60s"
}
Override Precedence
Fragment values integrate with the default system in a clear hierarchy:
- Default values are applied first
- Fragment values override defaults
- Explicit values in the using block override fragment values
No special syntax is needed to override a fragment value – simply set the property directly in the block that applies the fragment.
Examples
Shared Debounce Settings
Define timing constants once and reuse across all switch devices:
fragment "static" "standard_switch_timing" {
debounce = "5ms"
settle_time = "2ms"
deduplication = "50ms"
}
fragment "static" "opto_switch_timing" {
debounce = "10ms"
settle_time = "5ms"
deduplication = "20ms"
}
event_type "playfield_switch" {
fragments = ["static.standard_switch_timing"]
traits = ["device", "timestamped"]
required_fields {
device_id = "string"
state = "string"
}
}
Timed Mode Fragment
Create a reusable countdown that different modes can share:
fragment "dynamic" "mode_countdown" {
params = {
duration = "duration"
}
start_time = "${now()}"
time_remaining = "${max(0, duration - (now() - start_time))}"
expired = "${time_remaining <= 0}"
}
score "event" "hurry_up_collect" {
fragments = [{
name = "dynamic.mode_countdown"
duration = "20s"
}]
when = device.scoop.cleared
points = "${50000 * time_remaining / 20000}"
}
Scoring Pattern Fragment
Package common scoring patterns into a fragment:
fragment "static" "multiball_timing" {
mode_start_delay = "500ms"
mode_timeout = "60s"
combo_window = "5s"
}
flow "timed" "multiball_timer" {
fragments = ["static.multiball_timing"]
duration = multiball_duration
warning_time = multiball_warning
}
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) | Yes | Starting value, must match the variable type |
min | number | No | Minimum bound (numeric types only) |
max | number | No | Maximum bound (numeric types only) |
scope | string | No | Lifetime scope: "global", "session", "game", "player", "ball" (default: "game") |
formula | string | No | Computed variable expression |
computed | bool | No | Mark as computed (default: false) |
persist | bool | No | Persist across games (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 |
auto_decay | bool | No | Enable interval-based decay (default: false) |
decay_interval | duration | No | How often interval decay applies (e.g. "500ms", "1s") |
decay_amount | number | No | Amount subtracted each decay_interval |
decay_condition | expression | No | Expression that must be true for decay to apply; false pauses decay |
growth_rate | number | No | Continuous growth per second toward growth_to |
growth_to | number | No | Ceiling value for growth; uses max if omitted |
auto_grow | bool | No | Enable interval-based growth (default: false) |
grow_interval | duration | No | How often interval growth applies (e.g. "500ms", "1s") |
grow_amount | number | No | Amount added each grow_interval |
grow_to | number | No | Ceiling value for interval-based growth; uses max if omitted |
Types
| Type | Internal Type | Default Value | Description |
|---|---|---|---|
int | int64 | 0 | Scoring values, counters, numeric calculations |
float | float64 | 0.0 | Multipliers, physics, fractional values |
bool | bool | false | Flags, states, conditions |
string | UTF-8 string | "" | Text display, clip names, identifiers |
list | Dynamic array | [] | Collections of typed elements |
map | map[string]T | {} | Key-value associations |
probability | Distribution | (none) | Weighted random selection with bucket definitions |
timer | int64 (ms) | 0 | Time-tracking variable with countdown or elapsed mode |
Integer Constraints
variable "int" "bumper_value" {
initial = 1000
min = 100 # Minimum allowed value
max = 10000 # Maximum allowed value
step = 100 # Increment/decrement step size (default: 1)
}
Float Constraints
variable "float" "ramp_multiplier" {
initial = 1.0
min = 0.5
max = 10.0
step = 0.1
}
Float variables accept integer values and automatically promote them to float64.
String Constraints
variable "string" "player_name" {
initial = "PLAYER"
max_length = 16 # Maximum characters (default: 256)
pattern = "^[A-Z0-9 ]*$" # Regex validation
allowed_values = ["PLAYER", "GUEST"] # Whitelist of valid values
scope = "player"
}
List Variables
variable "list" "bumper_clips" {
element_type = "string" # string, int, float, bool, or mixed
initial = ["bumper_1", "bumper_2", "bumper_3"]
max_size = 10 # Maximum elements (default: 100)
unique_elements = false # Enforce unique values (default: false)
scope = "global"
}
Map Variables
variable "map" "mode_scores" {
element_type = "int" # Type of map values
initial = {
normal = 1000
multiball = 5000
wizard = 10000
}
scope = "player"
}
Timer Variables
Timer variables automatically update over time. The mode field selects the timer behavior.
Countdown timer — starts at initial and decrements to zero:
variable "timer" "mode_timer" {
initial = 30000 # 30 seconds in milliseconds
mode = "countdown"
scope = "player"
}
Elapsed timer — starts at zero and increments without an upper limit:
variable "timer" "game_time" {
mode = "elapsed"
scope = "global"
}
| Property | Type | Required | Description |
|---|---|---|---|
mode | string | Yes | "countdown" or "elapsed" |
initial | number | No | Starting millisecond value (countdown only; default: 0) |
Countdown behavior: Decrements over time. Stops at zero and never goes negative. Can be reset to initial.
Elapsed behavior: Increments over time starting from zero. No upper limit. Can be reset to zero.
Timer values are read and manipulated using timer operations in action blocks:
# Start, stop, or reset a timer
update {
mode_timer = mode_timer.start()
mode_timer = mode_timer.stop()
mode_timer = mode_timer.reset()
}
# Read current timer value in expressions
condition = var.mode_timer > 0
points = var.game_time * 10
Elapsed timers do not have on_expire actions since they never expire. on_tick actions work for both modes.
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 |
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:
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 "string" "device_name" { required = true }
parameter "string" "light_name" { required = true }
parameter "string" "light_device" { required = true }
parameter "int" "points" { default = 5000 }
score "event" "score" {
trigger = "device.${param.device_name}.rollover"
points = param.points
}
event_handler "turn_on" {
event = "device.${param.device_name}.rollover"
condition = "${param.light_name} == false"
actions {
toggle_variable = param.light_name
set_light "light" {
device = param.light_device
state = "on"
}
}
}
event_handler "turn_off" {
event = "device.${param.device_name}.rollover"
condition = "${param.light_name} == true"
actions {
toggle_variable = param.light_name
set_light "light" {
device = param.light_device
state = "off"
}
}
}
# Re-sync light state when variable changes for any reason
# (game end reset, player switch, external set)
event_handler "sync_on" {
event = "variable.${param.light_name}.changed"
condition = "${param.light_name} == true"
actions {
set_light "light" {
device = param.light_device
state = "on"
}
}
}
event_handler "sync_off" {
event = "variable.${param.light_name}.changed"
condition = "${param.light_name} == false"
actions {
set_light "light" {
device = param.light_device
state = "off"
}
}
}
}
With this pattern:
- Game end:
player-scopedl6_litresets tofalse→variable.l6_lit.changedfires →sync_offturns the physical light off - Player switch: P1 has
l6_lit = true, P2 hasl6_lit = false→ change event fires → light reflects the new player’s state - Player restore: When P1’s turn resumes,
l6_litrestores totrue→ light turns back on
Expression Syntax
Variable References
Variables are referenced with the var. prefix. Simple references need no interpolation wrapper:
points = var.bumper_value
condition = var.skill_shot_active
Inside expressions with operators or multiple variables, use interpolation syntax:
points = "${var.base_value} * ${var.multiplier}"
condition = "${var.loop_count} >= 3"
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
floor(x) # Round down
ceil(x) # Round up
round(x) # Round to nearest
abs(x) # Absolute value
clamp(x, min, max) # Constrain to range
if(condition, true_val, false) # Conditional value
default(value, fallback) # Fallback for null/undefined
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
There are two decay models. Continuous decay uses decay_rate (per second) and produces smooth value changes:
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
}
Interval-based decay uses auto_decay with decay_interval and decay_amount for precise step-wise countdown:
# Hurry-up value that counts down 500 points every 100ms
variable "int" "hurry_up_value" {
initial = 50000
min = 5000
scope = "ball"
auto_decay = true
decay_interval = "100ms" # 10 updates per second
decay_amount = 500 # Subtract 500 each interval
decay_to = 5000 # Stop at minimum collect value
}
Use decay_condition to pause decay based on game state:
# Combo timer that freezes during multiball animations
variable "int" "combo_timer" {
initial = 0
scope = "ball"
auto_decay = true
decay_interval = "100ms"
decay_amount = 100
decay_to = 0
decay_condition = "!var.combo_paused" # Pauses when combo_paused is true
}
For int variables, continuous 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.
Growing Variables
Growth is the inverse of decay. Continuous growth uses growth_rate (per second):
# Shield that regenerates over time
variable "float" "shield_strength" {
initial = 0.0
min = 0.0
max = 100.0
scope = "player"
growth_rate = 2.5 # Gain 2.5 per second
growth_to = 100.0 # Stop at full strength
}
Interval-based growth uses auto_grow with grow_interval and grow_amount for step-wise increases:
# Progressive jackpot building over time
variable "int" "progressive_jackpot" {
initial = 10000
max = 1000000
scope = "game"
auto_grow = true
grow_interval = "1s" # Add value every second
grow_amount = 100 # +100 points per second
grow_to = 1000000 # Stop at 1M
}
# Mystery value with risk/reward: collect now or wait?
variable "int" "mystery_value" {
initial = 5000
scope = "ball"
auto_grow = true
grow_interval = "500ms" # Grow twice per second
grow_amount = 250 # +250 each interval = +500/s
grow_to = 50000 # Cap at 50k
}
Growth always respects both growth_to and the variable’s max constraint — whichever is lower acts as the effective ceiling.
Combined Decay and Growth
A single variable can have both configured. Decay is applied first, then growth, on each tick. This enables competing-force mechanics:
# Temperature that cools passively but can be heated by events
variable "float" "coil_temperature" {
initial = 20.0
min = 0.0
max = 200.0
scope = "machine"
decay_rate = 1.0 # Cools 1.0°/s toward ambient
decay_to = 20.0 # Ambient temperature floor
growth_rate = 0.0 # Passive heating rate (set dynamically by rules)
growth_to = 200.0 # Maximum temperature ceiling
}
Map Access in Expressions
variable "map" "mode_scores" {
element_type = "int"
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]}"
}
Score
The scoring system defines how points are awarded, modified, and accumulated during gameplay. Scoring configuration uses three block types: score "event" for event-triggered point awards, score "modifier" for conditional variable changes, and score "accumulator" for special-purpose score tracking like combos and jackpots.
Score Event
Score events define point awards triggered by game events. A score event block takes the label "event" and a unique name.
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 scoring rule |
condition | expression | true | Boolean expression that must be true to score |
points | expression | required | Expression calculating 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 |
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 |
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} - ${var.combo_multiplier.decay_rate}, ${var.combo_multiplier.decay_to})"
}
}
# 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.
Accumulators support three types, each with different properties. Here is a sequence accumulator with combo scoring and milestone levels:
score "accumulator" "bumper_combo" {
type = "sequence"
events = ["bumper.*"]
window = "5s"
on_collect = game.combo_collected
collect_multiplier = var.combo_multiplier
reset_on_collect = true
combo {
base_multiplier = 1.0
max_multiplier = 10.0
multiplier_step = 0.5
}
levels {
threshold = 5
points = 5000
emit = "combo_milestone_5"
}
}
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 |
Accumulator Properties
| Property | Type | Applies To | Description |
|---|---|---|---|
window | duration | sequence | Time window for event matching |
pattern | list | sequence | Ordered event pattern to match |
events | list | sequence | Event glob patterns to collect |
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 |
Combo Configuration
The combo sub-block configures multiplier progression for sequence accumulators:
| Property | Type | Default | Description |
|---|---|---|---|
base_multiplier | float | 1.0 | Starting multiplier |
max_multiplier | float | 10.0 | Maximum multiplier cap |
multiplier_step | float | 0.5 | Multiplier increase per chain event |
Multiplier formula: min(base_multiplier + (chain_length - 1) * multiplier_step, max_multiplier)
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.activated
points = var.bumper_value
}
score "event" "slingshot_hit" {
when = device.slingshot.activated
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)"
combo_timer = 5000
}
}
Combo Accumulator with Milestones
score "accumulator" "bumper_combo" {
events = ["bumper.*"]
window = "5s"
combo {
base_multiplier = 1.0
max_multiplier = 10.0
multiplier_step = 0.5
}
levels {
threshold = 5
points = 5000
emit = "combo_milestone_5"
}
levels {
threshold = 10
points = 15000
emit = "combo_milestone_10"
}
}
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
}
Event Type
Event types define the schema and behavior for events in Cade. They specify required and optional fields, attach traits for common field sets, and establish the contract that all events of that type must satisfy.
Declaration Syntax
An event type block takes a unique name and declares its field schema along with optional traits for common field sets.
event_type "switch_hit" {
traits = ["device", "player", "timestamped"]
required_fields {
device_id = "string"
state = "string"
}
optional_fields {
velocity = "int"
force = "float"
}
}
Properties
| Property | Type | Default | Description |
|---|---|---|---|
traits | list | [] | Built-in trait sets to include |
required_fields | block | {} | Fields that must be present on the event |
optional_fields | block | {} | Fields that may be present |
Event Traits
Traits provide reusable field sets that are commonly needed across event types:
device
Adds hardware device identification fields:
| Field | Type | Description |
|---|---|---|
device_id | string | Unique device identifier |
device_type | string | Device category and type |
player
Adds player context fields:
| Field | Type | Description |
|---|---|---|
player_id | int | Player identifier |
player_num | int | Player number (1-based) |
timestamped
Adds temporal tracking fields:
| Field | Type | Description |
|---|---|---|
timestamp | time | When the event occurred |
frame | int | Game frame number |
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 |
| rollover | .rollover | device.top_rollover.rollover |
| loop | .cleared | device.left_loop.cleared |
| ramp | .cleared | device.center_ramp.cleared |
| orbit | .cleared | device.right_orbit.cleared |
| slingshot | .hit | device.left_slingshot.hit |
| bumper | .hit | device.pop_bumper.hit |
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
Event Flows
Events can be orchestrated through flow blocks that manage relationships between events over time.
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
}
}
Conditional Flow
Events guarded by prerequisite conditions:
flow "conditional" "super_jackpot" {
require {
"mode.multiball.active" = true
"var.jackpots_made" >= 3
}
events = ["shot.center_ramp"]
on_complete {
emit = "super_jackpot_collected"
points = "${var.jackpot_value * 5}"
}
}
Default Event Processing
The system automatically handles:
- Event validation against the declared schema
- Field type checking at event creation time
- Pattern matching optimization at compile time
- Event deduplication when configured
- Timestamp injection for timestamped events
Examples
Switch Event Type
event_type "switch_hit" {
traits = ["device", "player", "timestamped"]
required_fields {
device_id = "string"
state = "string"
}
optional_fields {
velocity = "int"
force = "float"
}
}
Custom Game Event
event_type "skillshot_made" {
traits = ["player", "timestamped"]
required_fields {
shot_type = "string"
difficulty = "int"
}
}
score "event" "skillshot_award" {
when = "skillshot_made"
points = "${1000 * event.difficulty}"
}
Combo Event with Flow
event_type "combo_made" {
traits = ["player", "timestamped"]
required_fields {
combo_type = "string"
shot_count = "int"
points_awarded = "int"
}
}
flow "accumulator" "combo_system" {
on_pattern_match {
emit = {
type = "combo_made"
combo_type = pattern_name
shot_count = pattern_length
points_awarded = points
}
}
}
Synth
A synth block declares a named synthesis patch — an oscillator-based sound effect generated in real time. Synth patches produce PCM audio without external audio files and feed into the same mixer pipeline used by audio_clip blocks.
See Synthesis for a usage guide with examples.
Declaration Syntax
synth "bumper_zap" {
oscillator "carrier" { wave = "saw" freq = 880 }
envelope "amp" {
attack = "2ms" decay = "30ms" sustain = 0.2 release = "80ms"
}
envelope "pitch" {
attack = "1ms" decay = "50ms" sustain = 0.0 release = "10ms"
depth = 400
}
filter {
type = "lowpass"
cutoff = 3000
resonance = 0.7
}
envelope "filter" {
attack = "1ms" decay = "60ms" sustain = 0.3 release = "100ms"
depth = 2000
}
modulate { source = "pitch" target = "carrier.freq" }
modulate { source = "filter" target = "filter.cutoff" }
polyphony = 64
}
Properties
| Property | Type | Default | Description |
|---|---|---|---|
polyphony | int | 64 | Maximum simultaneous voices for this patch (1–128). |
oscillator Block
Each synth must contain at least one named oscillator block. The oscillator label is a unique name used for modulation routing.
oscillator "carrier" {
wave = "sine"
freq = 440
amp = 1.0
}
| Property | Type | Default | Description |
|---|---|---|---|
wave | string | — | Required. Wave shape: "sine", "saw", "square", or "triangle". |
freq | float | — | Required. Base frequency in Hz. For carrier oscillators, overridden by the trigger pitch. |
amp | float | 1.0 | Amplitude. For modulator oscillators, this is the modulation depth in Hz. |
modulate Block
Routes a source signal into a target parameter. Sources can be oscillators (for FM synthesis) or named envelopes (for pitch sweeps and filter sweeps). Optional — omit for simple patches.
modulate {
source = "mod"
target = "carrier.freq"
}
| Property | Type | Default | Description |
|---|---|---|---|
source | string | — | Required. Name of a defined oscillator or envelope. |
target | string | — | Required. Target parameter: "<oscillator>.freq" or "filter.cutoff". |
The filter.cutoff target is only valid when the source is an envelope (not an oscillator) and a filter block is present in the synth.
The compiler validates that source references a defined oscillator or envelope, that target uses a supported format, and that oscillator modulation routing contains no cycles.
envelope Block
Required. At least one named envelope is required per synth, and exactly one must be named "amp" (controls amplitude). Additional envelopes can modulate oscillator frequency or filter cutoff via modulate blocks.
The label is optional — an unlabeled envelope { } defaults to "amp" for backwards compatibility.
envelope "amp" {
attack = "5ms"
decay = "40ms"
sustain = 0.3
release = "120ms"
}
envelope "pitch" {
attack = "1ms" decay = "50ms" sustain = 0.0 release = "10ms"
depth = 400
}
| Property | Type | Default | Description |
|---|---|---|---|
attack | duration | — | Required. Time to ramp from silence to full level. |
decay | duration | — | Required. Time to fall from full level to sustain. |
sustain | float | — | Required. Hold level while the note is active (0.0–1.0). |
release | duration | — | Required. Time to fade from sustain to silence. |
depth | float | 0 | Modulation depth in target units (Hz for pitch/filter cutoff). Ignored for the "amp" envelope. |
Durations use Go duration syntax: "5ms", "1s", "100us", "500µs", etc.
Envelope names must be unique within a synth and must not collide with oscillator names.
filter Block
Optional. Adds a biquad filter to the voice signal chain, applied after oscillator mixing and before amplitude envelope gain. At most one filter block per synth.
filter {
type = "lowpass"
cutoff = 3000
resonance = 0.7
}
| Property | Type | Default | Description |
|---|---|---|---|
type | string | — | Required. Filter type: "lowpass" or "highpass". |
cutoff | float | — | Required. Cutoff frequency in Hz. Must be > 0 and ≤ Nyquist (half the sample rate). |
resonance | float | — | Required. Resonance amount, 0.0–1.0. Higher values emphasize the cutoff frequency. |
Event Trigger Syntax
Synth patches are triggered from on_event blocks using a synth action block:
on_event "bumper_hit" {
synth {
name = "bumper_hit"
pitch = 440
velocity = 0.8
}
}
| Property | Type | Default | Description |
|---|---|---|---|
name | string | — | Required. Name of the compiled synth patch. |
pitch | float or binding | — | Pitch in Hz. Literal value or "from:event.<field>" binding. |
velocity | float or binding | — | Velocity 0.0–1.0. Literal value or "from:event.<field>" binding. |
Value Bindings
Pitch and velocity accept either literal numbers or from:event.<field> bindings that resolve values from the event’s data payload at dispatch time:
on_event "target_hit" {
synth {
name = "target_tone"
pitch = "from:event.pitch"
velocity = "from:event.velocity"
}
}
Validation
The compiler performs these checks at startup:
| Check | Error |
|---|---|
| No oscillators defined | at least one oscillator is required |
| Duplicate oscillator name | duplicate oscillator "<name>" |
| Invalid wave name | invalid wave "<name>": must be one of sine, saw, square, triangle |
| Missing envelope | at least one envelope is required |
No "amp" envelope | exactly one envelope named "amp" is required |
| Duplicate envelope name | duplicate envelope "<name>" |
| Envelope name collides with oscillator | envelope name "<name>" collides with oscillator name |
| Invalid duration | invalid <field> duration "<value>" |
| Sustain out of range | sustain must be 0.0-1.0 |
| Modulation source not defined | modulate source "<name>" is not a defined oscillator or envelope |
| Modulation target not defined | modulate target oscillator "<name>" is not defined |
| Unsupported modulation target | modulate target "<target>" is not supported (only <osc>.freq and filter.cutoff) |
filter.cutoff target with oscillator source | source must be an envelope, not an oscillator |
filter.cutoff target without filter block | a filter block is required when targeting filter.cutoff |
| Modulation cycle detected | modulation cycle detected: <a> -> <b> |
| Invalid filter type | filter type must be "lowpass" or "highpass" |
| Filter cutoff out of range | cutoff must be > 0 and ≤ Nyquist |
| Filter resonance out of range | resonance must be 0.0-1.0 |
| Polyphony out of range | polyphony must be 1-128 |
All errors include the HCL source location for diagnostics.
Examples
Simple Beep
synth "simple_beep" {
oscillator "tone" {
wave = "sine"
freq = 880
}
envelope "amp" {
attack = "1ms"
decay = "20ms"
sustain = 0.0
release = "10ms"
}
}
FM Bell Tone
synth "bell" {
oscillator "carrier" {
wave = "sine"
freq = 880
}
oscillator "mod" {
wave = "sine"
freq = 440
amp = 200
}
modulate {
source = "mod"
target = "carrier.freq"
}
envelope "amp" {
attack = "1ms"
decay = "100ms"
sustain = 0.0
release = "500ms"
}
polyphony = 32
}
Filtered Saw with Envelope Sweep
A sawtooth through a resonant low-pass filter with an envelope-driven cutoff sweep — a classic subtractive synthesis sound:
synth "bumper_zap" {
oscillator "carrier" { wave = "saw" freq = 880 }
envelope "amp" {
attack = "2ms" decay = "30ms" sustain = 0.2 release = "80ms"
}
envelope "pitch" {
attack = "1ms" decay = "50ms" sustain = 0.0 release = "10ms"
depth = 400
}
filter {
type = "lowpass"
cutoff = 3000
resonance = 0.7
}
envelope "filter" {
attack = "1ms" decay = "60ms" sustain = 0.3 release = "100ms"
depth = 2000
}
modulate { source = "pitch" target = "carrier.freq" }
modulate { source = "filter" target = "filter.cutoff" }
polyphony = 64
}
Aggressive Buzz
synth "alarm" {
oscillator "buzz" {
wave = "saw"
freq = 220
}
envelope "amp" {
attack = "10ms"
decay = "0ms"
sustain = 1.0
release = "50ms"
}
}
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 |
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 |
Location (optional)
location {
playfield_area = "upper_right"
coordinates { x = 10.5, y = 25.0 }
}
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 |
All switch devices also generate device.<name>.active and device.<name>.inactive events for raw state changes.
Event Aliases
Event aliases give mechanical devices the vocabulary their behavior calls for, without multiplying low-level names. Aliases are emitted on top of the raw active / inactive events — handlers can subscribe to whichever name reads most naturally.
| Alias | Emitted by | Meaning |
|---|---|---|
rested | switch drop | Target has settled in the up position after a reset pulse completes |
state_changed | any device with explicit state | The device’s declared state has transitioned (e.g., drop-target FSM) |
moved | motor stepper, motor servo | The device has begun moving toward a new target position |
reached | motor stepper, motor servo | The device has arrived at its commanded position |
moving | motor stepper, motor servo | The device is currently in motion (emitted continuously per tick) |
Aliases carry the same event payload as the underlying hardware event, plus a state field whose value matches the device’s declared state or position name.
Drop Targets
A switch drop declares a drop target that falls when hit and must be reset with a coil. Drop targets implement an implicit state machine — up → hit → resetting → up — and emit both the per-state aliases above and rested once the reset pulse completes.
device "switch" "drop" "drop_center" {
id = 0x51
tags = ["drop_bank", "center"]
reset {
coil = device.drop_reset_coil
pulse_ms = 30
debounce_ms = 50
}
}
| Sub-block | Field | Type | Default | Description |
|---|---|---|---|---|
reset | coil | device | required | Coil device that physically resets the target |
reset | pulse_ms | int | 30 | Coil pulse duration when firing the reset |
reset | debounce_ms | int | 50 | Quiet window after reset before the target is considered rested |
Events emitted: device.<name>.hit, device.<name>.state_changed, device.<name>.rested, plus the raw active/inactive.
Stand-up Targets
A switch standup declares an upright hit-only target. Unlike drop targets, stand-ups have no reset semantics — they stay upright after being struck.
device "switch" "standup" "standup_1" {
id = 0x61
tags = ["standup_bank"]
}
Events emitted: device.<name>.hit plus the raw active/inactive. There is no rested or reset {} sub-block.
Stand-ups are conventionally grouped with a shared tag so a single handler can score all targets in the bank:
score "event" "standup_hit" {
when = tag.standup_bank.hit
points = 500
}
Servo Positions
Servo motors (motor servo) support a positions {} block that assigns symbolic names to target positions. This keeps game logic driver-agnostic — the .cade file refers to named positions, and the platform driver maps those to the physical pulse width or step count.
device "motor" "servo" "left_diverter" {
id = 0x80
tags = ["diverter"]
positions {
home = 0
up = 90
down = -45
}
}
| Field | Type | Description |
|---|---|---|
home | number | Conventional rest/parked position (not required, but widely used as the default start) |
| any | number | Additional named positions (driver-agnostic scalar values) |
Events emitted: device.<name>.moved, device.<name>.moving, device.<name>.reached, and device.<name>.state_changed. The state field on each event carries the position name.
Behavior
The behavior {} sub-block declares hardware-level reflexes the device should perform without round-tripping through the scoring engine. Behaviors are the declarative analog of a light show: you describe the input, the action, and any guards, and the config compiler lowers them to autofire-style rules the platform driver can run locally.
device "switch" "bumper" "pop_bumper_1" {
id = 0x40
behavior "auto_fire_coil" {
when = device.pop_bumper_1.active
action = pulse_coil { device = device.pop_bumper_1_coil, pulse_ms = 20 }
cooldown_ms = 100
}
}
| Property | Type | Description |
|---|---|---|
when | expr | Event reference or runtime expression that triggers the action |
action | block | An action verb — see Action Verbs below |
guard | expr | Optional boolean expression; the behavior fires only when the guard is true |
cooldown_ms | int | Minimum time between firings, enforced at the driver level |
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 {
device = device.drop_reset_coil
pulse_ms = 30
}
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. Runtime expressions are also accepted.
set_state
set_state {
device = device.drop_center
state = "up"
}
trigger_behavior
trigger_behavior {
device = device.pop_bumper_1
name = "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.
Examples
Pop Bumper Assembly
device "switch" "bumper" "pop_bumper_1" {
id = 0x40
debounce {
activate_ms = 2
}
location {
playfield_area = "upper_center"
}
tags = ["pop_bumper", "playfield"]
}
device "coil" "bumper" "pop_bumper_1_coil" {
id = 0x30
pulse {
default_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 {
default_ms = 30
max_ms = 50
}
hold {
power_percent = 15
initial_kick_ms = 20
}
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 {
default_ms = 25
}
recycle_ms = 500
tags = ["trough"]
}
Drop Target Bank
device "coil" "standard" "drop_reset" {
id = 0x32
pulse {
default_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
}
}
score "event" "arm_diverter" {
when = signal.ramp_armed
action {
move_servo {
device = device.left_diverter
position = "up"
}
}
}
score "event" "park_diverter" {
when = device.left_diverter.reached
guard = event.state == "up"
action {
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.
Syntax
A platform block takes two labels: the driver type and an instance name. Multiple instances of the same driver type can coexist with different configurations. Cade includes three built-in platform drivers: FAST Pinball (serial hardware), gRPC (network bridge for external systems like Visual Pinball), and Virtual (software simulation for development and testing).
platform "fast" "main" {
net_port = "/dev/ttyUSB0"
baud = 921600
watchdog_ms = 1000
}
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 |
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) |
enable_gateway | bool | true | no | Enable 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 TLS certificate file |
key_file | string | "" | no | Path to TLS private key file |
enable_cors | bool | true | no | Enable CORS headers on the gateway |
When enable_tls is set to true, both cert_file and key_file must be provided.
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 noise generation context for use in expressions and procedural content. Each context selects an algorithm, a seeding strategy, and optional quality and cache settings.
Declaration Syntax
noise_context "scoring" {
algorithm = "hash"
seed_base = "time"
quality = "balanced"
cache_size = 256
}
The block requires exactly one label: the context name. All properties are optional and default to sensible values when omitted.
| Property | Type | Default | Description |
|---|---|---|---|
algorithm | string | "hash" | Noise algorithm: "perlin", "simplex", "hash", "white" |
seed_base | string or integer | "random" | Seeding strategy (see below) |
quality | string | (algorithm default) | Advisory quality hint: "fast", "balanced", "high_quality" |
cache_size | integer | 256 | LRU cache capacity; 0 uses the package default |
Algorithms
| Algorithm | Description |
|---|---|
"hash" | Fast, uniform hash noise — best for most scoring and procedural uses |
"white" | Pure random noise with no spatial coherence |
"simplex" | Gradient noise with smooth transitions — suitable for animations |
"perlin" | Classic gradient noise — similar to simplex but with slightly different characteristics |
Seed Base
The seed_base field controls how the noise context is seeded at startup.
| Value | Behavior |
|---|---|
"time" / "random" | Seeded from the wall-clock time at startup — non-deterministic |
"deterministic" | Fixed seed 0 — produces identical sequences across runs |
| integer literal | Explicit integer seed — use any value for a fixed sequence |
# Deterministic context for testing and replays
noise_context "test" {
algorithm = "hash"
seed_base = "deterministic"
}
# Fixed integer seed
noise_context "fixed" {
algorithm = "simplex"
seed_base = 42
}
Multiple Contexts
Multiple named contexts can coexist in the same configuration. Each context maintains its own independent state.
noise_context "scoring" {
algorithm = "hash"
seed_base = "time"
quality = "balanced"
cache_size = 256
}
noise_context "animations" {
algorithm = "simplex"
seed_base = "time"
quality = "high_quality"
}
noise_context "testing" {
algorithm = "hash"
seed_base = "deterministic"
}
Examples
Scoring Context
noise_context "scoring" {
algorithm = "hash"
seed_base = "time"
quality = "balanced"
}
Animation Context
noise_context "light_shows" {
algorithm = "simplex"
seed_base = "time"
quality = "high_quality"
cache_size = 512
}

