Reusing Config with Assemblies
Reusing Config with Assemblies
Real tables repeat themselves. Two flippers wired the same way. A bank of three drop targets that score identically. Four trough switches that differ only by hardware ID. Authored by hand, each copy is a place a typo can hide and a place you have to remember to change when the rule changes.
An assembly is the fix: you write the repeated group of blocks once, mark the bits that differ as parameters, and stamp out an instance per flipper, target, or lane with a use block. This guide takes a duplicated config and refactors it into an assembly step by step, then covers the full surface — parameters, instance generators, and tag propagation.
It assumes you’re comfortable with devices, variables, and scoring rules. If not, walk through Table Design Basics first.
Start with the duplication
Here are three standup targets. Each one is a switch, a lamp, a per-ball hit counter, and a scoring rule. The blocks are identical except for the hardware IDs, a position label, and the point value:
# --- Left target ---
device "switch" "standard" "left_sw" {
id = 0x30
debounce_ms = 4
}
device "light" "standard" "left_lamp" { id = 0x50 }
variable "int" "left_hits" {
initial = 0
scope = "ball"
}
score "event" "left_scored" {
when = device.left_sw.hit
points = 5000
}
# --- Center target ---
device "switch" "standard" "center_sw" {
id = 0x31
debounce_ms = 4
}
device "light" "standard" "center_lamp" { id = 0x51 }
variable "int" "center_hits" {
initial = 0
scope = "ball"
}
score "event" "center_scored" {
when = device.center_sw.hit
points = 10000
}
# --- Right target --- (and a third near-identical copy…)Three copies, and every copy is a maintenance liability. Add a “lamp flashes on hit” rule and you have to add it three times, correctly, in three places.
Extract the pattern into an assembly
An assembly "name" { … } block holds the repeated blocks once. Everything that varied between copies — the IDs, the label, the points — becomes a parameter, referenced inside the body as param.<name>:
assembly "target" {
parameter "int" "switch_id" { required = true }
parameter "int" "lamp_id" { required = true }
parameter "string" "position" { required = true }
parameter "int" "base_points" { default = 5000 } # optional — falls back to 5000
device "switch" "standard" "sw" {
id = param.switch_id
debounce_ms = 4
}
device "light" "standard" "lamp" {
id = param.lamp_id
}
variable "int" "hit_count" {
initial = 0
scope = "ball"
}
score "event" "scored" {
when = device.self.sw.hit # "self" = this instance's own sw device
points = param.base_points
}
}An assembly definition on its own produces nothing — it’s a template. You stamp out concrete blocks with use, passing a value for each parameter:
use "target" "left_target" {
switch_id = 0x30
lamp_id = 0x50
position = "left"
base_points = 5000
}
use "target" "center_target" {
switch_id = 0x31
lamp_id = 0x51
position = "center"
base_points = 10000
}
use "target" "right_target" {
switch_id = 0x32
lamp_id = 0x52
position = "right"
# base_points omitted → defaults to 5000
}The rule now lives in exactly one place. Add the lamp-flash behavior to the assembly body and all three targets get it.
Parameters
Each parameter "<type>" "<name>" { … } declares one input. The type label matches Cade’s value types — the common ones are int, number, string, bool, list, and map. Inside the assembly body, read the value as param.<name>.
| Field | Required | What it does |
|---|---|---|
required | no | When true, every use must supply this argument. Defaults to false. Don’t combine with default. |
default | no | Value used when a use block omits the argument. |
description | no | Human-readable note describing the parameter. |
validation | no | Constraint on accepted values, for example validation = "enum:easy,normal,hard". |
parameter "string" "mode" {
description = "Difficulty preset for this lane"
default = "normal"
validation = "enum:easy,normal,hard"
}Referring to an assembly’s own blocks
Inside the body, an instance refers to its own generated blocks with device.self.<name> (and the same self form for other block kinds). This is what keeps three instances from cross-talking — device.self.sw.hit in the target assembly resolves to this instance’s switch, never a sibling’s:
score "event" "scored" {
when = device.self.sw.hit # this instance's switch only
points = param.base_points
}When expanded, each instance’s blocks are renamed <instance>__<block> — so left_target’s switch becomes left_target__sw and center_target’s becomes center_target__sw. The self references are rewritten to match, guaranteeing isolation no matter how many instances you stamp out.
What can go inside an assembly
An assembly body isn’t limited to devices and scoring — it can hold any of the table’s building blocks, and every one is namespaced per instance on expansion:
| Block | What it contributes to each instance |
|---|---|
device | Hardware — switches, lights, coils |
variable | Per-instance state (e.g. scope = "ball" or "game") |
constant | Named fixed values |
score "event" | Scoring rules triggered by the instance’s events |
signal | Switch sequences / shots over the instance’s devices |
event_handler | Reactions to the instance’s events, with an actions { … } body |
audio_clip | Sounds the instance can play |
route / selection | Per-instance audio routing, addressed with owners = device.self.<name> |
use | Another assembly, nested inside this one (see Nesting assemblies) |
Each is renamed <instance>__<block> on expansion just like devices, and self references inside them resolve to the instance’s own blocks. For example, a constant, signal, event_handler, and audio_clip defined in a target assembly’s body become left_target__MAX_HITS, left_target__combo, and so on for instance left_target.
Generating many instances at once
When you need N near-identical instances, you don’t have to write N use blocks. Two generator meta-arguments expand a single use into many. They’re mutually exclusive — use one or the other.
count — a fixed number of instances, suffixed __0, __1, …:
use "trough_switch" "opto" {
count = 4
}
# → opto__0, opto__1, opto__2, opto__3
# devices: opto__0__sw, opto__1__sw, …for_each over a list — one instance per element, suffixed by the value:
use "pop_bumper" "bumper" {
base_points = 500
for_each = ["top", "left", "right"]
}
# → bumper__top, bumper__left, bumper__rightfor_each over a map — one instance per key, with per-instance values:
use "drop_target" "target" {
for_each = {
red = { id = 0x30, points = 5000 }
yellow = { id = 0x31, points = 3000 }
}
}
# → target__red, target__yellow (keys expand in sorted order)Nesting assemblies
An assembly can use another assembly in its body, so you compose larger units from smaller ones. The namespaces chain: a lamp device inside an indicator assembly, nested into a target assembly as instance ind, expands to <target-instance>__ind__lamp.
assembly "indicator" {
parameter "int" "lamp_id" { required = true }
device "light" "standard" "lamp" { id = param.lamp_id }
}
assembly "target" {
parameter "int" "sw_id" { required = true }
parameter "int" "lamp_id" { required = true }
device "switch" "standard" "sw" { id = param.sw_id }
# nest the indicator assembly, passing a parameter through
use "indicator" "ind" { lamp_id = param.lamp_id }
}
use "target" "left" {
sw_id = 0x30
lamp_id = 0x50
}
# → devices: left__sw, left__ind__lampNesting is bounded for safety: assemblies may nest up to 10 levels deep, and a cycle — two assemblies that use each other — is rejected at load time with the offending path, e.g. circular assembly nesting: b -> a -> b.
Tags propagate from three sources
Tags are how you address groups of devices later (for device_control selectors, bulk operations, dashboards). An assembly unions tags from three places onto every device it generates:
- Assembly-level
tags— applied to every device in every instance. - Instance-level
tagson theuseblock — applied to every device in that instance. - Device-level
tagsinside the body — applied to that one device.
assembly "bumper" {
tags = ["autofire"] # ① every device, every instance
parameter "int" "switch_id" { required = true }
parameter "int" "coil_id" { required = true }
device "switch" "bumper" "sw" {
id = param.switch_id
tags = ["sensor"] # ③ just this switch
}
device "coil" "bumper" "coil" { id = param.coil_id }
}
use "bumper" "pop_1" {
switch_id = 0x40
coil_id = 0x30
tags = ["upper_playfield"] # ② every device in pop_1
}
# pop_1's switch ends up tagged: autofire, upper_playfield, sensor
# pop_1's coil ends up tagged: autofire, upper_playfieldThe result is the deduplicated union — no need to repeat a shared tag on every block.
Referencing an instance from outside
Other blocks reach an assembly’s generated devices by their expanded names. You can write the logical form device.assembly.<instance>.<block> and Cade rewrites it to <instance>__<block> during expansion:
use "flipper" "left_flipper" {
button_id = 0x11
coil_id = 0x01
}
use "flipper" "right_flipper" {
button_id = 0x12
coil_id = 0x02
}
# A combo that needs both flipper buttons:
signal "combo" "double_flip" {
switches = [
device.assembly.left_flipper.button,
device.assembly.right_flipper.button,
]
}Assemblies, fragments, and modules
Three blocks reduce repetition, each for a different shape of reuse:
| Block | Reuses | Reach for it when… |
|---|---|---|
assembly / use | A parameterized group of hardware + rules | You have physical repeats — flippers, targets, lanes — that differ only by ID, label, or value. |
fragment | A bundle of scoring values pulled into score rules | You repeat the same points / condition values across multiple score "event" rules. |
module | A bundle of game logic (modes, scoring, variables, audio) | You’re packaging a behavior — a multiball mode, a tilt handler — not a piece of hardware. |
Where to go next
- See every block and attribute — the Table Configuration reference covers
assembly/usealongside devices, scoring, and platforms. - Write the expressions inside
pointsandcondition— Writing Expressions. - Look a term up — the Glossary has quick definitions for assembly, fragment, and module.