NAV

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:

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

Configuration Precedence

Settings are resolved from highest to lowest priority:

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

Environment Variables

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

VariableMaps To
CADE_WEB_ENABLEDweb.enabled
CADE_WEB_PORTweb.port
CADE_WEB_HOSTweb.host
CADE_WEB_HEALTH_ENABLEDweb.health.enabled
CADE_WEB_DEBUG_ENABLEDweb.debug.enabled
CADE_WEB_LOGGING_ENABLEDweb.logging.enabled
CADE_LOGGING_LEVELlogging.level
CADE_LOGGING_FORMATlogging.format
CADE_ANALYTICS_ENABLEDanalytics.enabled
CADE_ANALYTICS_BACKEND_TYPEanalytics.backend_type

Top-Level Blocks

web

Controls the built-in web server for health monitoring and debugging.

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:

BlockPurpose
devicePhysical hardware declarations (switches, coils, lights)
event_typeCustom event type definitions with traits and fields
variableGame state variables with types, scopes, and constraints
scoreScoring rules, modifiers, and accumulators
fragmentReusable configuration snippets (static and dynamic)
pragmaValidation, optimization, and feature control directives
platformPlatform driver configuration (FAST, gRPC, Virtual)
synthOscillator-based synthesis patches (real-time PCM, no files)
assembly / useReusable parameterized block templates and their instances
moduleBundled 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

DirectiveTypeDefaultDescription
modestring"normal"Validation mode: strict, normal, or relaxed
optimization_levelint1Performance optimization level (0-3)
experimentalboolfalseEnable experimental features
deprecated_okboolfalseAllow deprecated features without errors
cache_expressionsboolfalseEnable expression result caching
cache_size_mbint10Maximum cache size in megabytes
errors_as_warningslist[]Error types to downgrade to warnings
disable_checkslist[]Validation checks to skip

Downgradable Error Types

These error types can be listed in errors_as_warnings:

These error types cannot be downgraded:

Validation Modes

strict

All validation errors are fatal. No undefined variables allowed. No implicit type conversions. All warnings promoted to errors. Maximum runtime safety checks.

normal

Syntax errors are fatal. Type mismatches produce warnings. Undefined variables default to zero values. Basic runtime safety checks. Suitable for active development.

relaxed

Only critical syntax errors are fatal. Minimal type checking. Maximum compatibility. Minimal runtime checks. Suitable for rapid prototyping.

Optimization Levels

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

Component Sub-Blocks

variables

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

scoring

DirectiveTypeDefaultDescription
modestringinheritedOverride validation mode for scoring
optimization_levelintinheritedOverride optimization for scoring
parallel_evaluationboolfalseEnable parallel scoring evaluation
cache_size_mbint10Scoring expression cache size

events

DirectiveTypeDefaultDescription
parallel_processingboolfalseEnable parallel event handling
max_workersint4Worker pool size for parallel events
queue_sizeint1000Event queue capacity
timeout_msint5000Event processing timeout

devices

DirectiveTypeDefaultDescription
validation_levelstring"normal"Device validation strictness
debounce_defaultsboolfalseApply 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:

  1. Default values are applied first
  2. Fragment values override defaults
  3. 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"
}
PropertyTypeRequiredDescription
initial(varies)YesStarting value, must match the variable type
minnumberNoMinimum bound (numeric types only)
maxnumberNoMaximum bound (numeric types only)
scopestringNoLifetime scope: "global", "session", "game", "player", "ball" (default: "game")
formulastringNoComputed variable expression
computedboolNoMark as computed (default: false)
persistboolNoPersist across games (default: false)
decay_ratenumberNoContinuous decay per second toward decay_to
decay_tonumberNoFloor value for decay; uses min if omitted
auto_decayboolNoEnable interval-based decay (default: false)
decay_intervaldurationNoHow often interval decay applies (e.g. "500ms", "1s")
decay_amountnumberNoAmount subtracted each decay_interval
decay_conditionexpressionNoExpression that must be true for decay to apply; false pauses decay
growth_ratenumberNoContinuous growth per second toward growth_to
growth_tonumberNoCeiling value for growth; uses max if omitted
auto_growboolNoEnable interval-based growth (default: false)
grow_intervaldurationNoHow often interval growth applies (e.g. "500ms", "1s")
grow_amountnumberNoAmount added each grow_interval
grow_tonumberNoCeiling value for interval-based growth; uses max if omitted

Types

TypeInternal TypeDefault ValueDescription
intint640Scoring values, counters, numeric calculations
floatfloat640.0Multipliers, physics, fractional values
boolboolfalseFlags, states, conditions
stringUTF-8 string""Text display, clip names, identifiers
listDynamic array[]Collections of typed elements
mapmap[string]T{}Key-value associations
probabilityDistribution(none)Weighted random selection with bucket definitions
timerint64 (ms)0Time-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"
}
PropertyTypeRequiredDescription
modestringYes"countdown" or "elapsed"
initialnumberNoStarting 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:

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

Scope Lifecycle

Session vs Game Scope

The session scope fills a gap between global (persists forever) and player (per-player turn). Session variables reset at game boundaries but are shared across all players within a game — they are not player-keyed. Use session for state that should survive player switches but clear when the game ends.

# Resets every ball
variable "bool" "skill_shot_active" {
  initial = true
  scope   = "ball"
}

# Persists across balls for one player
variable "int" "bonus_multiplier" {
  initial = 1
  scope   = "player"
  max     = 10
}

# Shared across players, resets each game
variable "int" "table_bonus_level" {
  initial = 0
  scope   = "session"
  max     = 5
}

# Persists across all games
variable "int" "high_score" {
  initial = 0
  scope   = "global"
}

Computed Variables

Computed variables derive their value from an expression referencing other variables. They recalculate automatically when dependencies change:

variable "int" "ramp_progressive_value" {
  formula  = var.ramp_base_value + (var.ramp_count * 1000)
  computed = true
}

variable "int" "total_multiplier" {
  formula  = "${var.base_multiplier} * ${var.mode_multiplier}"
  computed = true
}

Change Events

When a variable’s value changes, Cade emits a variable.<name>.changed event. This allows event handlers to react to variable mutations from any source — gameplay actions, lifecycle resets, or player switches.

Event Shape

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

The reason field indicates the mutation source:

ReasonDescription
setExplicit set_variable action in an event handler
toggletoggle_variable action
resetLifecycle reset (game end, ball start, etc.)
player_switchActive player changed; player-scoped value now differs

Events are only emitted when the value actually changes — a set_variable that writes the same value produces no event.

Synchronizing Lights with Variables

The change event is particularly useful for keeping physical lights in sync with variable state. Without it, lights set by an event handler would go stale on player switches and game-end resets — the variable resets but the light stays in its last state.

An assembly can opt into synchronization by listening for the change event:

assembly "rollover_logic" {
  parameter "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:

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

PropertyTypeDefaultDescription
whenstringrequiredEvent that activates this scoring rule
conditionexpressiontrueBoolean expression that must be true to score
pointsexpressionrequiredExpression calculating points to award
priorityint100Execution order (lower runs first)
enabledbooltrueWhether this rule is active
emitstring(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

PropertyTypeDefaultDescription
typestring"conditional"Modifier type: conditional or timer
conditionexpression(none)Boolean guard for conditional modifiers
whenstring(none)Event that activates this modifier
intervalduration(none)Update interval for timer modifiers
priorityint100Execution order (lower runs first)
enabledbooltrueWhether this modifier is active

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

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

Accumulator Properties

PropertyTypeApplies ToDescription
windowdurationsequenceTime window for event matching
patternlistsequenceOrdered event pattern to match
eventslistsequenceEvent glob patterns to collect
contribute_percentintprogressivePercentage of each qualifying score to add
contribute_whenexpressionprogressiveCondition for score contribution
thresholdintthresholdValue that triggers collectability
reset_on_collectboolallReset accumulated value after collection
on_collectstringallEvent that triggers collection
collect_multiplierexpressionallMultiplier applied at collection time

Combo Configuration

The combo sub-block configures multiplier progression for sequence accumulators:

PropertyTypeDefaultDescription
base_multiplierfloat1.0Starting multiplier
max_multiplierfloat10.0Maximum multiplier cap
multiplier_stepfloat0.5Multiplier 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

PropertyTypeDefaultDescription
traitslist[]Built-in trait sets to include
required_fieldsblock{}Fields that must be present on the event
optional_fieldsblock{}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:

FieldTypeDescription
device_idstringUnique device identifier
device_typestringDevice category and type

player

Adds player context fields:

FieldTypeDescription
player_idintPlayer identifier
player_numintPlayer number (1-based)

timestamped

Adds temporal tracking fields:

FieldTypeDescription
timestamptimeWhen the event occurred
frameintGame frame number

Naming Conventions

Events follow a hierarchical dot-separated naming pattern:

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

Device vs Signal Suffixes

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

Device TypeSuffixExample
spinner.spindevice.left_spinner.spin
target.hitdevice.center_target.hit
rollover.rolloverdevice.top_rollover.rollover
loop.cleareddevice.left_loop.cleared
ramp.cleareddevice.center_ramp.cleared
orbit.cleareddevice.right_orbit.cleared
slingshot.hitdevice.left_slingshot.hit
bumper.hitdevice.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:

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

PropertyTypeDefaultDescription
polyphonyint64Maximum 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
}
PropertyTypeDefaultDescription
wavestringRequired. Wave shape: "sine", "saw", "square", or "triangle".
freqfloatRequired. Base frequency in Hz. For carrier oscillators, overridden by the trigger pitch.
ampfloat1.0Amplitude. 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"
}
PropertyTypeDefaultDescription
sourcestringRequired. Name of a defined oscillator or envelope.
targetstringRequired. 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
}
PropertyTypeDefaultDescription
attackdurationRequired. Time to ramp from silence to full level.
decaydurationRequired. Time to fall from full level to sustain.
sustainfloatRequired. Hold level while the note is active (0.0–1.0).
releasedurationRequired. Time to fade from sustain to silence.
depthfloat0Modulation 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
}
PropertyTypeDefaultDescription
typestringRequired. Filter type: "lowpass" or "highpass".
cutofffloatRequired. Cutoff frequency in Hz. Must be > 0 and ≤ Nyquist (half the sample rate).
resonancefloatRequired. 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
  }
}
PropertyTypeDefaultDescription
namestringRequired. Name of the compiled synth patch.
pitchfloat or bindingPitch in Hz. Literal value or "from:event.<field>" binding.
velocityfloat or bindingVelocity 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:

CheckError
No oscillators definedat least one oscillator is required
Duplicate oscillator nameduplicate oscillator "<name>"
Invalid wave nameinvalid wave "<name>": must be one of sine, saw, square, triangle
Missing envelopeat least one envelope is required
No "amp" envelopeexactly one envelope named "amp" is required
Duplicate envelope nameduplicate envelope "<name>"
Envelope name collides with oscillatorenvelope name "<name>" collides with oscillator name
Invalid durationinvalid <field> duration "<value>"
Sustain out of rangesustain must be 0.0-1.0
Modulation source not definedmodulate source "<name>" is not a defined oscillator or envelope
Modulation target not definedmodulate target oscillator "<name>" is not defined
Unsupported modulation targetmodulate target "<target>" is not supported (only <osc>.freq and filter.cutoff)
filter.cutoff target with oscillator sourcesource must be an envelope, not an oscillator
filter.cutoff target without filter blocka filter block is required when targeting filter.cutoff
Modulation cycle detectedmodulation cycle detected: <a> -> <b>
Invalid filter typefilter type must be "lowpass" or "highpass"
Filter cutoff out of rangecutoff must be > 0 and ≤ Nyquist
Filter resonance out of rangeresonance must be 0.0-1.0
Polyphony out of rangepolyphony 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

CategoryDescriptionExample Types
switchAll switch typesslingshot, opto, spinner, target, drop, standup, rollover, bumper, button, gate, trough, mechanical
coilAll coil/solenoid typesstandard, flipper, diverter, motor, bumper
lightAll light typesled, rgb, gi_string, flasher
displayAll display typesdmd, lcd, segment
motorStepper and servo motorsstepper, servo
sensorAdvanced sensorsaccelerometer, ir

Switch subtypes target, drop, and standup are all distinct. Use target for generic hit-only targets that do not need the drop/standup semantics below; use drop for targets that fall and must be reset with a coil; use standup for upright hit-only targets that are conventionally grouped into banks.

Common Properties

These properties are available on all device blocks:

PropertyTypeDefaultDescription
idhex/intrequiredHardware identifier
tagslist[]Grouping tags for bulk references
descriptionstring""Human-readable description
part_numberstring""Replacement part number
notesstring""Service notes
enabledbooltrueWhen false, the device is declared but ignored by the runtime — inputs are dropped and action verbs become no-ops

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
}
PropertyTypeDefaultDescription
activate_msint2Milliseconds stable before activation
release_msint2Milliseconds stable before release
typestring"active_low"Edge detection type
sample_countint3Required consecutive samples

Automatic Event Generation

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

CategoryTypeGenerated Events
switchspinnerdevice.<name>.spin
switchtargetdevice.<name>.hit
switchrolloverdevice.<name>.rollover
switchloopdevice.<name>.cleared
switchrampdevice.<name>.cleared
switchorbitdevice.<name>.cleared
switchslingshotdevice.<name>.hit
switchbumperdevice.<name>.hit
switchbuttondevice.<name>.hit
switchoptodevice.<name>.cleared
switchgatedevice.<name>.cleared
switchtroughdevice.<name>.active, device.<name>.cleared

All switch devices also generate device.<name>.active and device.<name>.inactive events for raw state changes.

Event Aliases

Event aliases give mechanical devices the vocabulary their behavior calls for, without multiplying low-level names. Aliases are emitted on top of the raw active / inactive events — handlers can subscribe to whichever name reads most naturally.

AliasEmitted byMeaning
restedswitch dropTarget has settled in the up position after a reset pulse completes
state_changedany device with explicit stateThe device’s declared state has transitioned (e.g., drop-target FSM)
movedmotor stepper, motor servoThe device has begun moving toward a new target position
reachedmotor stepper, motor servoThe device has arrived at its commanded position
movingmotor stepper, motor servoThe device is currently in motion (emitted continuously per tick)

Aliases carry the same event payload as the underlying hardware event, plus a state field whose value matches the device’s declared state or position name.

Drop Targets

A switch drop declares a drop target that falls when hit and must be reset with a coil. Drop targets implement an implicit state machine — up → hit → resetting → up — and emit both the per-state aliases above and rested once the reset pulse completes.

device "switch" "drop" "drop_center" {
  id   = 0x51
  tags = ["drop_bank", "center"]

  reset {
    coil        = device.drop_reset_coil
    pulse_ms    = 30
    debounce_ms = 50
  }
}
Sub-blockFieldTypeDefaultDescription
resetcoildevicerequiredCoil device that physically resets the target
resetpulse_msint30Coil pulse duration when firing the reset
resetdebounce_msint50Quiet window after reset before the target is considered rested

Events emitted: device.<name>.hit, device.<name>.state_changed, device.<name>.rested, plus the raw active/inactive.

Stand-up Targets

A switch standup declares an upright hit-only target. Unlike drop targets, stand-ups have no reset semantics — they stay upright after being struck.

device "switch" "standup" "standup_1" {
  id   = 0x61
  tags = ["standup_bank"]
}

Events emitted: device.<name>.hit plus the raw active/inactive. There is no rested or reset {} sub-block.

Stand-ups are conventionally grouped with a shared tag so a single handler can score all targets in the bank:

score "event" "standup_hit" {
  when   = tag.standup_bank.hit
  points = 500
}

Servo Positions

Servo motors (motor servo) support a positions {} block that assigns symbolic names to target positions. This keeps game logic driver-agnostic — the .cade file refers to named positions, and the platform driver maps those to the physical pulse width or step count.

device "motor" "servo" "left_diverter" {
  id   = 0x80
  tags = ["diverter"]

  positions {
    home = 0
    up   = 90
    down = -45
  }
}
FieldTypeDescription
homenumberConventional rest/parked position (not required, but widely used as the default start)
anynumberAdditional named positions (driver-agnostic scalar values)

Events emitted: device.<name>.moved, device.<name>.moving, device.<name>.reached, and device.<name>.state_changed. The state field on each event carries the position name.

Behavior

The behavior {} sub-block declares hardware-level reflexes the device should perform without round-tripping through the scoring engine. Behaviors are the declarative analog of a light show: you describe the input, the action, and any guards, and the config compiler lowers them to autofire-style rules the platform driver can run locally.

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
  }
}
PropertyTypeDescription
whenexprEvent reference or runtime expression that triggers the action
actionblockAn action verb — see Action Verbs below
guardexprOptional boolean expression; the behavior fires only when the guard is true
cooldown_msintMinimum 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.

VerbTargetsPurpose
pulse_coilcoil deviceFire a coil for a bounded duration
move_servomotor servo deviceCommand a servo to a named positions {} entry
set_stateany stateful deviceTransition a device’s declared state (e.g., drop-target FSM)
trigger_behaviorany device with behavior {} named blocksFire a named behavior on demand

pulse_coil

pulse_coil {
  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"
  }
}
SettingTypeDescription
strengthintFlipper kick strength (physics-abstract)
hold_timedurationHow long the flipper holds in the up position

Kicker Settings

Kicker devices support kick physics parameters in their settings block. These values are pushed to the platform driver during registration and consulted when a kick_ball action references the kicker by name.

device "switch" "kicker" "Kicker1" {
  id = 41
  settings {
    kick_angle    = 190
    kick_strength = 10
  }
}
SettingTypeDescription
kick_angleintKick angle in degrees (physics-abstract)
kick_strengthintKick strength (physics-abstract, 0 = device default)

The VPX driver maps these to Kicker::Kick(angle, speed, inclination) parameters. A physical machine driver would map them to coil pulse parameters. The kick_ball action block itself carries only the device name, keeping game logic driver-agnostic.

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

PropertyTypeDefaultRequiredDescription
net_portstring(none)yesSerial port for the NET processor
exp_portstring""noSerial port for the EXP processor
baudint921600noSerial baud rate (must be positive)
platformstring"2000"noFAST platform code ("2000" for Neuron)
watchdog_msint1000noWatchdog timeout in milliseconds (non-negative)

gRPC

The gRPC driver exposes a gRPC server that external systems (such as Visual Pinball X) connect to for bidirectional communication.

platform "grpc" "vpx_bridge" {
  port              = 50051
  enable_gateway    = true
  enable_reflection = true
  enable_tls        = false
  enable_cors       = true
}

Properties

PropertyTypeDefaultRequiredDescription
portint50051nogRPC server listen port (1-65535)
enable_gatewaybooltruenoEnable gRPC-gateway REST proxy
enable_reflectionbooltruenoEnable gRPC server reflection
enable_tlsboolfalsenoEnable TLS encryption
cert_filestring""noPath to TLS certificate file
key_filestring""noPath to TLS private key file
enable_corsbooltruenoEnable 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

PropertyTypeDefaultRequiredDescription
switch_countint64noNumber of virtual switches
coil_countint32noNumber of virtual coils
light_countint128noNumber of virtual lights
servo_countint8noNumber of virtual servos

All count values must be non-negative.

Examples

Visual Pinball Development Setup

platform "grpc" "vpx_bridge" {
  port              = 50051
  enable_reflection = true
  enable_gateway    = true
  enable_cors       = true
}

FAST Hardware with Dual Processors

platform "fast" "main" {
  net_port    = "/dev/ttyUSB0"
  exp_port    = "/dev/ttyUSB1"
  baud        = 921600
  platform    = "2000"
  watchdog_ms = 1000
}

Headless Testing

platform "virtual" "test" {
  switch_count = 128
  coil_count   = 64
  light_count  = 256
  servo_count  = 16
}

Noise Context

A noise_context block declares a named 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.

PropertyTypeDefaultDescription
algorithmstring"hash"Noise algorithm: "perlin", "simplex", "hash", "white"
seed_basestring or integer"random"Seeding strategy (see below)
qualitystring(algorithm default)Advisory quality hint: "fast", "balanced", "high_quality"
cache_sizeinteger256LRU cache capacity; 0 uses the package default

Algorithms

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

ValueBehavior
"time" / "random"Seeded from the wall-clock time at startup — non-deterministic
"deterministic"Fixed seed 0 — produces identical sequences across runs
integer literalExplicit 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
}