Repository navigation
Spikes Ball State Ownership
ItemManager._set_item_placement is the single owner of role state for every owned item. Ball.play_state becomes the physics presentation of the ball-role placement, kept in sync by BallReconciler listening to item_placement_changed. Equipment-role items already have no Ball; ItemManager already owns their placement; pinning the same owner for balls removes the drift without inventing a third state machine.
Two state fields describe ball role:
-
Ball.play_state(scripts/entities/ball/ball.gd:36): taxonomy described in02-ball-lifecycle.md. Funnelled throughset_play_state;enter_*helpers flip physics flags and call the funnel. -
ItemManager.state.item_placements(scripts/items/item_manager.gd:335, via_set_item_placement):STORED,EQUIPPED,ON_COURT, with aLOOSE_IN_VENUEoverlay instate.loose_in_venue. Persisted inItemState.
Both fields carry an idea of "where this ball is in the run." They are written from different call sites and rarely cross-check each other. Concrete drift surfaces:
-
ItemDragController.enter_out_held(item_drag_controller.gd:228,:293) setsplay_state = OUT_HELDwithout touchingitem_placements; the placement still readsON_COURT(or whatever it was). TheLOOSE_IN_VENUEoverlay only fires when the gesture actually deposits the ball on the floor (:361,:511). -
ItemManager._set_item_placementemitscourt_changed;BallReconciler._on_court_changedcallsenter_storedon deactivate, but aLOOSE_IN_VENUEwrite does not changeplay_stateat all; the ball is still whatever the drag controller most recently set. - Save-restore reads
ball_play_states(fromBall.play_state) anditem_placementsindependently; nothing reconciles them on load.
BallReconciler._balls_by_key is the third surface and stays as-is; see 02-ball-lifecycle.md and memory project_ball_tracker_membership_is_existence.
item_placements is the source of truth for role. Ball.play_state becomes a derived view: ball reads its placement on transition and applies the matching physics config. BallReconciler listens to item_placement_changed and routes through Ball.enter_* helpers for the cases that need a Ball.
Pros:
- Equipment items already live here; one owner across all roles.
-
item_placement_changedis the broadcast every UI consumer already wires up (rack display, equipped panel, speed bar, paddle stats). - Save shape unchanged;
item_placementswas already persisted. - Removes the need for
collect_ball_play_statesand the matching save field; a placement plus aLOOSE_IN_VENUEflag already encodes the same five states the ball needs (moduloPLAY_NORMAL/PLAY_ARC, see below).
Cons:
-
PLAY_NORMALvsPLAY_ARCis a physics detail not inPlacement. The split stays inBall, derived fromglobal_position.yandfriendship_bound_y; not a role placement, not promoted to ItemManager. -
OUT_HELDis currently driven by the drag controller before any placement write happens; the controller must callItemManager.mark_loose_in_venue(or a newmark_held) at gesture start, not only at gesture end.
Ball.play_state is the source of truth; ItemManager derives item_placements from a registry walk.
Pros:
- Physics flags and state already co-located in
Ball.
Cons:
- Equipment has no Ball. Either equipment lives in a different ownership model (the drift we're trying to remove), or a phantom
Ball-like state machine has to exist for equipment, which is worse than today. -
item_placement_changedwould have to be re-derived and re-emitted from ball-side events, inverting the current signal wiring (every UI subscriber would re-wire). - Save / load currently pulls placement from ItemState; flipping the source means a save-format change.
Rejected: equipment-role is the load-bearing case and Ball cannot own it.
New ItemRoleStateMachine autoload (or per-item state objects) that ItemManager and Ball both subscribe to.
Pros:
- Symmetric.
Cons:
- Net adds a third surface to the two we already disagree about. The dispatch's escalation rule names this as a "rename play_state AND _placement AND introduce a new owner" shape and tells us not to ship it as one spike. Skipped.
Equipment items have no Ball and never will. They live entirely in item_placements: STORED on the gear rack, EQUIPPED on the paddle. There is no OUT_HELD-equivalent equipment state today; the held-mid-drag regime in 22-equip-loop-regime.md rejects-or-accepts at the drop target and never writes a "carried" placement.
Under the chosen owner, equipment continues to write through _set_item_placement exactly as today. The new contract for the rest of the codebase is "any role-state question goes to ItemManager." The split between _set_item_placement (immediate placement write) and mark_loose_in_venue (overlay flag) stays for ball-role only; equipment does not use the overlay.
If a future "equipment held mid-gesture" state is needed (e.g. a wrap visibly in the player's hand mid-swap), it lands as a sibling overlay on ItemManager, not on a phantom Ball.
A presentation enum, not a role enum. Two responsibilities survive on it:
-
PLAY_NORMALvsPLAY_ARC(the physics split below / above the friendship bound). - The integration-frame physics flags (
gravity_scale, damping, freeze, miss-detection suppression) theBallStateConfigresources apply.
The role-level transitions (STORED, OUT_REST, OUT_HELD) collapse into: "Ball reacts to a placement change broadcast and runs the matching enter_* helper." Ball.set_play_state stops being a public funnel and becomes an internal step inside the helpers.
collect_ball_play_states and ItemState.ball_play_states retire; placement plus position is enough.
-
OUT_HELDno longer reads asON_COURTinitem_placements. The drag controller callsmark_loose_in_venue(or a renamedmark_held) at gesture start; the ball follows. - Save-restore reads one source for role;
_apply_saved_play_statecollapses to aplacement -> enter_*switch driven by ItemManager. -
_on_court_changedextends to cover every placement transition, not just on/off-court for ball-role items.
The consolidation breaks into independent slices. Each holds against current main, and the order between them does not matter; later slices just see fewer redundant call sites.
The earliest move introduces a HELD overlay on ItemManager, sibling to LOOSE_IN_VENUE, with mark_held and clear_held entry points. The drag controller calls them at gesture start and release. Ball's play_state write stays where it is for this slice; ItemManager just gains the second source of truth, which closes the "OUT_HELD reads as ON_COURT" drift before any reader migrates.
A separate slice migrates the readers. BallReconciler subscribes to item_placement_changed and routes into Ball.enter_*, extending the existing _on_court_changed handler. Direct enter_* calls from the drag controller stay in place during this slice. Both paths converge on the same Ball, and set_play_state is idempotent enough to make the double-write safe.
The save shape collapses on its own. SaveManager stops pulling ball_play_states; _apply_saved_play_state reconstructs role from item_placements plus the HELD and LOOSE_IN_VENUE overlays. The save field either bumps per 03-save-versioning.md or rides serialised-but-ignored until the next planned wipe.
The legacy write path and the legacy name retire together. The drag controller stops calling enter_* directly; the reconciler-driven route becomes the only writer of the Ball field, which lets set_play_state go private. The field renames play_state -> physics_state in the same change, and 02-ball-lifecycle.md rewrites to match (prose to HELD, enum references to physics_state). All three moves close the same loop, so they belong together: the field stops being a role surface, and the doc stops naming the retired surface.
-
Ball.play_statebecomesBall.physics_state. The residual job isPLAY_NORMALvsPLAY_ARCplus the integration-frame physics flags;physics_statereads true to that job and stops the field from competing withitem_placementsas a role surface. The rename lands with the legacy-write-path retirement slice, onceset_play_stategoes private. -
HELDis the name for held-mid-gesture, on ItemManager.Ball.play_state = OUT_HELDis the old name for the same state. The lifecycle doc Mermaid already usesHELD; this spike pins the noun and lands it on the single owner. The Ball enum value retires whenplay_stateretires.LOOSE_IN_VENUEstays as its own overlay for floor-deposit. - These names ride into the migration. Implementation work inherits them; it does not rename them.
-
BallReconciler._balls_by_keymembership semantics. Already correct per memoryproject_ball_tracker_membership_is_existence.
Contributing
The game
The Cast
Process
Reference