Multiball and Ball Lifecycle Events

Multiball and Ball Lifecycle Events

Cade emits a fixed set of system.ball.* and system.play.* events as a ball turn progresses. Getting multiball right means picking the right ball-firing action (eject_ball vs kick_ball), listening for the right lifecycle event, and tracking in-play ball counts correctly.

eject_ball vs kick_ball

Two actions drive ball devices, and they are not interchangeable.

eject_ballkick_ball
Use forTrough or launcher: a new ball enters play.Gate or held-ball kicker: an existing ball is fired out.
Ball countIncrements the session’s in-play ball count.Does not change the in-play ball count — the ball is already in play.
Physics parametersOn the action block (angle, strength).On the kicker device declaration (settings { kick_angle, kick_strength }).

Using eject_ball where kick_ball is correct can spawn a phantom ball on each gate hit (the simulator creates a new ball and kicks it). For kicker-held balls, always use kick_ball. See Kick Ball Action for the full action reference.

Ball Drain Behavior

When a ball drains, the session decides which branch to take based on how many balls are still in play, whether the player has extra balls, and whether other players remain.

ScenarioConditionEvents emitted (in order)Result
Multiball intermediate drainMore than one ball still in playsystem.ball.drainDecrement in-play count, no turn change
Extra ballPlayer has an extra ballsystem.ball.drainsystem.ball.endRelaunch for same player
Game overLast ball drains, all players finishedsystem.ball.drainsystem.play.endsystem.ball.endEnd the game
Player rotationLast ball drains, other players remainsystem.ball.drainsystem.play.endsystem.ball.endAdvance to next player

The key invariant: system.ball.end does not fire on multiball intermediate drains. A mode with stop_events = ["system.ball.end"] therefore stays active through the whole multiball sequence — it ends only when the last ball drains.

Emission order is strict within each branch: system.ball.drain first, then system.play.end (last-ball branches only), then system.ball.end. This ordering lets modes that listen for system.play.end run before anything that reacts to system.ball.end.

system.ball.end Payload

Handlers listening for system.ball.end can read the following fields:

FieldDescription
playerCurrent player ID.
ballWhich ball just ended (1-indexed).
final_scorePlayer’s score at ball end.
drain_deviceDevice that triggered the drain.
timestampTime the event was emitted.

Tracking In-Play Ball Counts

Two independent counters are involved in multiball — make sure each is updated by the right action.

For trough ejection, increment both (session count is automatic, HCL variable via an increment action). For gate-to-kicker flows, only the HCL variable needs updating.

Sequence Example — Gate-to-Kicker Multiball

1. system.ball.start              (in-play count = 1)

2. device.Gate1.cleared
   ├─ kick_ball Kicker1           (ball already in play, no ejection)
   ├─ increment var.balls_in_play
   └─ start_mode multiball

3. device.Drain.activated         (first ball drains)
   └─ system.ball.drain           (in-play count drops from 2 to 1)

      Mode's drain handler:
        condition = var.balls_in_play <= 1
        action:   emit = "multiball.ended"

4. device.Drain.activated         (last ball drains)
   ├─ system.ball.drain
   ├─ system.play.end
   └─ system.ball.end             → advance to next player

Configuration Example

device "switch" "kicker" "Kicker1" {
  id = 41
  settings {
    kick_angle    = 190
    kick_strength = 10
  }
}

event_handler "gate_hit" {
  event = "device.Gate1.cleared"

  actions {
    kick_ball { device = "Kicker1" }
    increment  = "balls_in_play"
    start_mode = "multiball"
  }
}

Trough ejection looks similar, but uses eject_ball with physics on the action:

event_handler "drain_auto_serve" {
  event = "device.Drain.activated"

  actions {
    eject_ball {
      device   = "BallRelease"
      angle    = 90
      strength = 7
    }
  }
}