Kick Ball Action
Kick Ball — Driver-Agnostic Kicker-Kick Primitive
The kick_ball action fires an existing ball held by a kicker device without creating a new ball or incrementing the ball-in-play count. It is the counterpart to eject_ball, which creates a new ball and adds it to the playfield for trough/launcher flows.
Problem
The gate-to-kicker-to-multiball flow in the example tables was broken. The original handlers used pulse_coil "Kicker1", but the cade-bridge plugin’s PulseCoil dispatch has no BALL_DEVICE handler, so device.Kicker1.activated never fired and the multiball flow was blocked.
A prior attempt replaced pulse_coil with eject_ball { device = "Kicker1" angle = 190 strength = 10 }. This was reverted for two reasons:
- Phantom ball creation. VPX’s
EjectBallplugin API doesCreateBall() + Kick()— every gate hit spawned a new ball, producing perma-multiball on the first plunge. - Incorrect ball counting.
executeEjectBallcallsballTracker.AddBallToPlay(), which is wrong for the kicker case where the ball is already in play and just needs to be fired out.
The fix is a kick-only primitive that is semantically distinct from ejection.
Design Constraints
The cade action must be driver-agnostic. Angle and strength are physics parameters that differ between platforms (VPX maps them to Kicker::Kick(angle, speed, inclination); a physical machine driver would map them to coil pulse parameters). Putting these values on the action block would bleed VPX physics into .cade game logic.
The solution follows the existing flipper precedent: physics parameters live on the kicker’s device declaration in a settings { } block. Flippers already use settings { strength = 75 hold_time = "250ms" } — values that are physics-abstract and interpreted by each driver. Kickers follow the same pattern with settings { kick_angle = 190 kick_strength = 10 }.
Architecture
The kick_ball action spans four layers, from HCL configuration through gRPC dispatch:
┌────────────────────────────────────────────────────────────────┐
│ .cade Configuration │
│ │
│ device "switch" "kicker" "Kicker1" { │
│ id = 41 │
│ settings { │
│ kick_angle = 190 ← physics-abstract, per-kicker │
│ kick_strength = 10 │
│ } │
│ } │
│ │
│ event_handler "gate_hit" { │
│ event = "device.Gate1.cleared" │
│ actions { │
│ kick_ball { device = "Kicker1" } ← device name only │
│ } │
│ } │
└────────────────────┬───────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ Event Handler Executor │
│ ┌──────────────────┐ ┌──────────────────────┐ │
│ │ kick_ball │─────►│ commander.KickBall │ │
│ │ action │ │ (gRPC to platform) │ │
│ └──────────────────┘ └──────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ NO ballTracker.AddBallToPlay() call │ │
│ │ Ball is already in play — this is the sharp │ │
│ │ semantic difference from executeEjectBall │ │
│ └──────────────────────────────────────────────────────┘ │
└────────────────────┬───────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ gRPC Platform Driver │
│ │
│ platform.KickBall{Device} → KickBallCommand{device_name} │
│ │
│ KickerConfig{device_name, kick_angle, kick_strength} │
│ pushed once during registration via PlatformConfigUpdate │
└────────────────────┬───────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ VPX cade-bridge Plugin │
│ │
│ KickBallCommand received: │
│ 1. Look up stored KickerConfig for device_name │
│ 2. Call vpxApi->KickBall(name, angle, speed, 0.0) │
│ 3. KickBall wraps Kicker::Kick() — NO CreateBall() │
│ 4. Return -4 if kicker has no ball held │
└────────────────────────────────────────────────────────────────┘
kick_ball vs eject_ball
eject_ball | kick_ball | |
|---|---|---|
| Use case | Trough/launcher: new ball enters play | Gate/kicker: existing held ball fires out |
| Ball creation | VPX: CreateBall() + Kick() | VPX: Kick() only, no CreateBall() |
| Ball tracking | Calls ballTracker.AddBallToPlay() | Does NOT call AddBallToPlay() |
| Physics params | On the action block (angle, strength) | On the device declaration (settings { kick_angle, kick_strength }) |
| Proto message | EjectBallCommand | KickBallCommand |
| Empty device | Creates a ball regardless | Returns error code -4 (no ball held) |
KickBallAction Type
// KickBallAction kicks an existing ball held by a kicker device.
// Does NOT create a new ball or increment ball-in-play count.
type KickBallAction struct {
Device string `json:"device"`
}
The action carries only the device name. Physics parameters are absent by design — they are parsed from the kicker device’s settings { } block and pushed to the bridge during registration via KickerConfig.
Commander Interface
// KickBall fires an existing ball held by a kicker device.
// Distinct from EjectBall which creates a new ball for trough/launcher flows.
KickBall(deviceName string) error
Executor
executeKickBall mirrors executeEjectBall structurally but with the critical semantic difference:
func (e *Executor) executeKickBall(kb types.KickBallAction, handlerName string) {
if err := e.commander.KickBall(kb.Device); err != nil {
e.logger.Warn("Failed to kick ball",
"handler", handlerName,
"device", kb.Device,
"error", err)
}
// Intentionally NO ballTracker.AddBallToPlay() — the ball is already
// in play. This is the sharp semantic difference from executeEjectBall.
}
Proto Messages
// KickBallCommand kicks an existing ball held by a kicker device.
// Carries only the device name — angle/strength are pushed once via
// KickerConfig in PlatformConfigUpdate.
message KickBallCommand {
string device_name = 1;
}
// KickerConfig carries per-kicker kick physics parameters from cade
// to the bridge. Pushed during registration / config update, stored
// by the bridge, and consulted when a KickBallCommand arrives.
message KickerConfig {
string device_name = 1;
float kick_angle = 2;
float kick_strength = 3;
}
KickerConfig is added as a repeated field on PlatformConfigUpdate, alongside the existing autofire_rules field. The bridge stores configs in a local map keyed by device name.
Kicker Device Settings
The parser recognizes kick_angle and kick_strength as known settings keys for kicker-subtype devices. These are stored in DeviceConfig.Properties and read during registration to populate KickerConfig messages:
device "switch" "kicker" "Kicker1" {
id = 41
settings {
kick_angle = 190
kick_strength = 10
}
}
This mirrors the flipper settings pattern:
device "flipper" "standard" "left_flipper" {
id = 1
hardware {
coil = "C01"
switch = "S01"
}
settings {
strength = 75
hold_time = "250ms"
}
}
Both are physics-abstract — any driver (VPX, physical machine, virtual) can interpret the values according to its own physics model.
VPX Bridge Dispatch
When the bridge receives a KickBallCommand:
- Look up stored
KickerConfigfor the device name. If missing, logWARNand skip. - Call
vpxApi->KickBall(name, angle, speed, 0.0f). KickBallimplementation mirrorsEjectBall’s element lookup but skipsCreateBall()andRelease(). It explicitly checkskicker->HasHeldBall()and returns-4if no ball is held (distinct fromKicker::KickXYZ’s silent no-op).- On return code
-4, logWARNwith device name. This is distinct from the “no config” warning so failures are diagnosable.
Return codes: 0 success, -1 no game, -2 element not found, -3 not a kicker, -4 no ball held.
Example Configuration
The gate-to-kicker-to-multiball flow with kick_ball:
device "switch" "kicker" "Kicker1" {
id = 41
settings {
kick_angle = 190
kick_strength = 10
}
}
# Gate -> Kicker -> Multiball
event_handler "gate_hit" {
event = "device.Gate1.cleared"
actions {
kick_ball { device = "Kicker1" }
increment = "balls_in_play"
start_mode = "multiball"
}
}
Compare with the trough/launcher ejection flow using eject_ball:
event_handler "drain_auto_serve" {
event = "device.Drain.activated"
actions {
eject_ball {
device = "BallRelease"
angle = 90
strength = 7
}
}
}
The two actions are orthogonal: eject_ball is for adding a new ball to the playfield; kick_ball is for firing a ball already held by a kicker.
Testing
Unit Tests
Executor kick (pkg/cade/events/handler/executor_test.go):
commander.KickBall()is called with the correct device name.BallTracker.AddBallToPlay()is not called — the anti-regression guard against accidentally reintroducing eject_ball semantics.- No panic when
ballTrackeris nil (graceful no-op).
Parser (pkg/cade/game/parser/event_handler_test.go):
kick_ball { device = "Kicker1" }parses successfully intoKickBallAction.- Unknown attributes (e.g.,
angle,strength) are rejected with a schema error — strict schema enforcement prevents physics parameters from leaking into the action block.
gRPC driver (pkg/cade/platform/drivers/grpc/driver_test.go):
platform.KickBall{Device: "Kicker1"}dispatches toKickBallCommand{DeviceName: "Kicker1"}.
End-to-End Scenario
A scenario test replays a gate hit and asserts:
KickBallCommandis dispatched (notEjectBallCommand).device.Kicker1.activatedfires, triggering the multiball start path.- Ball-in-play count is not incremented by the kick_ball action itself — only the explicit
increment = "balls_in_play"in the handler config changes the HCL variable.