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:

  1. Phantom ball creation. VPX’s EjectBall plugin API does CreateBall() + Kick() — every gate hit spawned a new ball, producing perma-multiball on the first plunge.
  2. Incorrect ball counting. executeEjectBall calls ballTracker.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_ballkick_ball
Use caseTrough/launcher: new ball enters playGate/kicker: existing held ball fires out
Ball creationVPX: CreateBall() + Kick()VPX: Kick() only, no CreateBall()
Ball trackingCalls ballTracker.AddBallToPlay()Does NOT call AddBallToPlay()
Physics paramsOn the action block (angle, strength)On the device declaration (settings { kick_angle, kick_strength })
Proto messageEjectBallCommandKickBallCommand
Empty deviceCreates a ball regardlessReturns 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:

  1. Look up stored KickerConfig for the device name. If missing, log WARN and skip.
  2. Call vpxApi->KickBall(name, angle, speed, 0.0f).
  3. KickBall implementation mirrors EjectBall’s element lookup but skips CreateBall() and Release(). It explicitly checks kicker->HasHeldBall() and returns -4 if no ball is held (distinct from Kicker::KickXYZ’s silent no-op).
  4. On return code -4, log WARN with 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):

Parser (pkg/cade/game/parser/event_handler_test.go):

gRPC driver (pkg/cade/platform/drivers/grpc/driver_test.go):

End-to-End Scenario

A scenario test replays a gate hit and asserts:

  1. KickBallCommand is dispatched (not EjectBallCommand).
  2. device.Kicker1.activated fires, triggering the multiball start path.
  3. 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.