Skip to content

physics: never collide a ball that was not hit-tested - #579

Merged
freezy merged 2 commits into
masterfrom
fix/kicker-created-ball
Sep 18, 2026
Merged

freezy merged 2 commits into
masterfrom
fix/kicker-created-ball

Conversation

@freezy

@freezy freezy commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Summary

A ball created inside a kicker below the playfield (a modelled trough exit) was sometimes launched from playfield level instead of from the kicker, flying over the apron. Traced with the simulation tracer in a standalone build.

Root cause

A ball's collision event is a struct whose default value reads as a hit with collider 0 at time 0, and a new ball state was created without initialising it. Balls held in a kicker are skipped by the hit-test loop, which is where the event normally gets cleared, but the collision phase ran for every ball, frozen or not. A ball created in a kicker and frozen there before its first physics step therefore had collider 0's collide routine applied to it, without any hit test. On the affected table collider 0 is a floor at z 0, whose push-out lifted the ball from the trough exit to one radius above the playfield with x and y untouched. When the kick arrived a tick later, it launched the ball from there. Whether the kick landed in the same tick as the capture decided if it happened, which is why it was intermittent.

Fix

  • New balls start with an explicit "no collision" event (CollisionEventData.None).
  • The static and ball-to-ball collision phases return for frozen balls.
  • The contact phase leaves a ball alone that got frozen during the same phase: a ball captured by a kicker mid-phase still had its earlier contacts resolved, and the contact position recovery could move it out of the kicker.

This restores the invariant VP has: a locked ball carries no hit object, and a ball only collides with what the hit test of the same cycle found.

Diagnostics

Every ball created in a kicker now logs its spawn position and the kicker's local, playfield and world position, and the capture logs where the ball ended up and the capture height the kicker state holds. Each kicker logs its capture height once at initialisation. A kicker rotated by a rotator mech also keeps its height, since the rotated position only carried x and y.

Tests

StaleCollisionEventTests: a new ball has no pending collision; a frozen ball with the default event over a floor is neither moved by the static phase nor bounced by a stale ball-to-ball event. Physics suites pass headlessly (the play-mode fixtures need a keyboard in batch mode and are unaffected).

A ball created in a trough exit kicker a hundred units below the
playfield still left from playfield level in a standalone build, while
most balls of the same session left from the kicker's height. Nothing
in the code moves that kicker, and the trace cannot tell which of the
two transforms-derived heights was wrong. Every created ball now logs
its spawn position together with the kicker's local, playfield and
world position, the capture logs where the ball ended up and which
capture height the kicker state holds, and each kicker logs its capture
height once at initialization.

A kicker rotated by a rotator mech also keeps its height instead of
dropping to playfield level, since the rotated position only carries x
and y.
A new ball's collision event started out as the struct default, which
reads as a hit with collider 0 at time 0. Balls held in a kicker skip
the hit tests, so that event was never cleared, but the collision phase
ran for every ball, held or not. A ball created in a kicker and frozen
there before its first physics step therefore collided with whatever
collider 0 happened to be. On a table where that is the playfield
floor, the ball was pushed from the trough exit, a hundred units below
the playfield, up to playfield level with its x and y untouched, and the
kick that followed a tick later launched it over the apron. Whether the
kick arrived in the same tick as the capture decided if it happened.

New balls now start with no pending collision, and the collision and
contact phases leave frozen balls alone, as VP does: a locked ball
carries no hit object, and every hit object is consumed after its
collision. The contact case is VPE's own: a ball captured by a kicker
during the collision phase still had its earlier contacts resolved,
whose position recovery could move it out of the kicker.
@greptile-apps

greptile-apps Bot commented Sep 18, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the stale-collision fix is consistent with the physics-cycle ordering and captured-ball lifecycle.

Summary

This PR restores the invariant that collision response only operates on balls hit-tested during the current physics cycle.

  • Initializes new balls with an explicit no-collision event.
  • Prevents static, dynamic, and contact response from moving frozen balls based on stale state.
  • Preserves kicker height when a rotator updates its X/Y position.
  • Adds kicker spawn and capture diagnostics plus regression tests for stale collision events.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Begin physics iteration] --> B{Ball frozen?}
  B -->|Yes| C[Skip hit testing]
  B -->|No| D[Clear collision event]
  D --> E[Static, kinematic, and ball hit tests]
  E --> F[Displacement]
  C --> F
  F --> G{Ball frozen at response time?}
  G -->|Yes| H[Skip static and dynamic response]
  G -->|No| I[Resolve tested collision]
  H --> J{Frozen before contact response?}
  I --> J
  J -->|Yes| K[Skip stale contacts]
  J -->|No| L[Resolve current contacts]
Loading

Reviews (1) · Last reviewed commit: "physics: never collide a ball that was n..."

@freezy
freezy merged commit 79c0324 into master Sep 18, 2026
15 checks passed
@freezy
freezy deleted the fix/kicker-created-ball branch September 18, 2026 09:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant