Hand-written — see footer
Chaos Hockey cover art

Systems Map

The major systems, how they actually talk to each other, and where the cost is. The Code Inventory lists every class; this page is the argument about how they fit together — which is the thing you cannot scrape.

Program flow

Launch to a running match. Each arrow is a real scene change or a real call, not an abstraction.

CharacterSelectScene pick or create a profile │ DrillSession.Player = profile → ProfileActivation.Activate ▼ ModeSelectScene Vision Training, or Chaos │ ├──────────────► DrillSelectionScene ──► VisionTrainingScene │ pick a drill, DrillHost owns the run: │ difficulty, length ready gate → countdown → play → results ▼ MatchSetupScene periods and length → MatchSetup.Request(...) │ ▼ main.tscn the rink │ ├─ PlayerMain._Ready binds the active profile, builds every player-facing screen ├─ MatchFlowManager StartMatch → MatchClock ticks while State == Playing ├─ TeamTacticalManager rebuilds a per-team snapshot; picks ONE active puck ├─ AITeamCoordinator turns the snapshot into per-player assignments └─ AIPlayer × N resolves a movement target, then an offensive action

How the systems are wired together

Four mechanisms carry almost all the coupling in this codebase. Knowing which one a dependency uses tells you most of what you need to know about it.

MechanismReachWhat it means in practice
GameServices 29 services
103 files
A static service locator. Nearly every system reaches nearly every other through it, which makes the dependency graph almost fully connected on paper and very hard to reason about. Its saving grace is that every access is null-checked, so a missing service degrades rather than crashes — the same instinct that makes the AI coordinator optional.
Static session state DrillSession
MatchSetup
Carries choices across scene changes, where nothing else survives. This is the single most bug-prone mechanism in the project: it has caused a dead Escape key in Chaos mode and a dead D-pad, both because a static outlived the scene that set it and nothing clears it. MatchSetup is the corrected pattern — it is consumed, so it cannot leak into a later match.
Scene-tree scans GetNodesInGroup
21 call sites
How pucks and players are found. Four of those sites sit inside per-frame methods. Correct, and the reason several "which puck?" bugs were possible — scene-tree order is not a gameplay rule, and until recently pickup and steal both treated it as one.
Polled input Input.IsActionPressed Distinct from Godot's event pipeline, and the distinction has bitten twice. SetInputAsHandled() stops an event propagating and does nothing to a poll, which is why the pause panel flickered open and closed on one press.

Where the cost is

Measured — the heat-map grid rebuild

The one place with real numbers, from a performance audit recorded in the source and in git. ScoringOpportunityManager rebuilds a grid of the whole ice, and each rebuild does multi-ray raycasts per goal per cell plus O(cells × players) defender-distance checks.

The debounce (SpatialRebuildDelay, 0.01s in main.tscn) essentially never completes during live play — ten moving skaters and a puck keep crossing grid cells — so the hard cap MaximumRebuildDelay is the real governor. It was firing at 0.35s: a full rebuild ~2.86 times a second, continuously. Raised to 0.5s for a ~30% cut, changing cadence only — no ray count, cell size or scoring weight was touched.

This system already instruments itself: LastRebuildMilliseconds, RebuildCount and MergedRebuildRequestCount are live counters. It is the only system that can currently answer a performance question with data.

Measured — allocation, not frequency, is what hurts

Found on 2026-09-24 and worth repeating because it generalises: the expensive mistake in a per-frame HUD update is not the update count, it is what the update allocates. An AddThemeStyleboxOverride handed a freshly constructed StyleBoxFlat every frame is sixty throwaway Godot Resources a second to recolour one bar. Cache per state, write only on change, gate the redraw.

Optimisation candidates

These are candidates, not findings. They are reasoned from the code, and none has been profiled. Calling any of them "the bottleneck" without measuring would repeat a mistake this project has already paid for — a rendering bug that took eleven rounds because each round measured a hypothesis instead of the symptom.

1. Pass analysis is recomputed per carrier, per throttle tick

Every candidate receiver gets a lane evaluation, and LaneEvaluator fires a fan of rays per candidate. With a full roster that is rays × receivers × carriers. A deliberately-deferred caching fix for this is recorded in git — deferred because caching a pass target would make an in-flight pass aim at a frozen position, which is a correctness change rather than a tuning one. Any fix here must not freeze the target.

2. Per-frame scene-tree scans

Four GetNodesInGroup call sites are inside per-frame methods, and each walks every puck in the scene. Cheap with one puck; Mayhem allows a hundred. The puck list changes rarely compared to how often it is read, which is the classic shape for a cached, invalidated collection.

3. Fifty-four per-frame entry points

24 _PhysicsProcess and 30 _Process implementations. Many are throttled already — RefreshGate, DecisionInterval and MaximumRebuildDelay appear across 24 sites — but the coverage is uneven, and the project's own UI-08 milestone records that older readouts were never migrated onto the gate.

4. AI tactical rebuild for a single puck

Not a cost problem today — it is a cost problem waiting. The tactical snapshot is rebuilt per team on a cadence for one puck (see Chapter 32). Making the AI see all twenty would multiply that work, so the fix and the optimisation have to be designed together rather than in sequence.

What would settle this

Godot's Performance singleton, logging frame time and allocations per session, charted on the dashboard. It is on the roadmap and not started. Until it exists, the heat-map rebuild is the only cost anybody can speak about with evidence, and everything above it on this page is an argument rather than a measurement.