perf(serverwater): O(actors*global_waters) → O(actors*per_area_waters) in per-tick underwater check#270
Conversation
Adds `Field FirstWater.ServerWater` on Area and `Field NextWater.ServerWater` on ServerWater, populated at ServerLoadArea time via head-insert into A\FirstWater. The global `For Each ServerWater` collection still owns every record (allocation / Delete go through Each); the new chain is an O(1)-lookup index into the subset belonging to each Area. Both the load-time insert and the dangling-on-area-unload semantics are documented inline so a future reader doesn't second-guess the chain maintenance: - ServerLoadArea links each newly-allocated water into A\FirstWater (head-insert; chain order doesn't affect semantics). - ServerUnloadArea Deletes both the chain heads (via Delete A) and every linked ServerWater (via the existing after-cursor walk at ServerUnloadArea) in a single call, so no caller can observe a stale NextWater pointer. No behavioural change. The companion GameServer.bb hot-path swap (per-tick underwater check) is in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UpdateActorInstances runs once per tick per ActorInstance. The underwater-damage check at this site previously walked the global `For SW.ServerWater = Each ServerWater` collection and filtered by `If SW\Area = AInstance\Area`. The cost was O(actors * total_water_in_ all_loaded_areas) per tick. On a server running 5 zones with 10 water regions each (50 total) and 300 actors (mix of players + NPCs), that was 15,000 inner-loop iterations per tick just for the water check. Walk AInstance\Area\FirstWater instead (built in the companion ServerAreas.bb commit). New cost: O(actors * waters_in_this_actor's_ area), typically 5-10 per area, so ~3,000 iterations in the example above. The per-iteration body is byte-identical to the original; behaviour is unchanged (same hit-test, same `Underwater = Handle(SW)` recording, same early Exit on first water-region match). The `Exit` keyword still works the same inside `While...Wend` as it did inside `For...Next` -- breaks out of the innermost loop. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 93850c089a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| W\NextWater = A\FirstWater | ||
| A\FirstWater = W |
There was a problem hiding this comment.
Preserve water iteration order within each area
This head-insert reverses each area's water order relative to load/file order, but UpdateActorInstances stops on the first matching water (Exit in the hit-test loop), so overlapping water volumes can now select a different ServerWater than before. In maps where overlaps have different Damage, DamageType, or surface Y, actors will take different damage/breath behavior after this change even though the old global scan was deterministic by creation order.
Useful? React with 👍 / 👎.
Summary (non-technical)
The server checks every actor against every water region in every loaded zone, every server tick — even when only one zone's water can possibly intersect that actor. This PR indexes water regions by their owning zone and walks only the relevant subset. On a 5-zone server with 10 water regions each and 300 actors, that's ~3,000 inner-loop iterations per tick instead of ~15,000. Behaviour is byte-identical; only the iteration is faster.
Technical summary
UpdateActorInstances()(GameServer.bb:665) is the per-tick actor update loop. Inside its body, the underwater-damage check at line 737 previously walked the globalFor SW.ServerWater = Each ServerWatercollection and filtered each candidate viaIf SW\Area = AInstance\Area. Cost: O(actors × total_water_in_all_loaded_areas) per tick.Indexing change:
AreaField FirstWater.ServerWater— head of the per-Area chainServerWaterField NextWater.ServerWater— chain linkPopulated at
ServerLoadAreatime via head-insert intoA\FirstWater(chain order is irrelevant — the hit-test Exits on the first match). The globalFor Each ServerWatercollection still owns every record (allocation / Delete go throughEach); the new chain is an O(1)-lookup index into the subset belonging to each Area.The per-tick hot path in
GameServer.bbnow reads:New cost: O(actors × waters_in_this_actor's_area) per tick.
Chain-lifecycle invariants (documented inline)
ServerLoadArealine 247-248): order doesn't matter; the underwater checkExitson the first match.ServerUnloadAreaDeletes bothA(which kills the chain head) and everyServerWater(via the existing after-cursor walk that filters byW\Area = A) in a single call. No caller can observe a staleNextWaterpointer.SaveAreastill walks the globalFor Each ServerWaterwith the existingIf W\Area = Afilter. Not a perf-critical path; left alone.Acceptance criteria
Areatype gainsFirstWater.ServerWaterfield with inline documentationServerWatertype gainsNextWater.ServerWaterfield with inline documentationServerLoadAreahead-inserts each new water intoA\FirstWaterGameServer.bbunderwater check walks the per-Area chain instead ofFor Each ServerWaterUnderwater = Handle(SW)recording, same earlyExit)compile.batclean across Server + Client + GUE + Project Manager + 7 Tools (exit 0, unfiltered)test.bat19/19 greenTrade-offs / deferred
SaveAreanot converted to the per-Area chain (still walksFor Each+ filter). Not a per-tick hot path; the conversion would add code without measurable benefit and risks breaking the save-format-stable ordering. Acknowledged in the inline comment.ServerUnloadAreanot converted to walk via the per-Area chain (still uses the after-cursor walk). Same reasoning — runs once per area unload, not per tick. The dangling-NextWater-on-Area-Delete invariant is documented inline so a future contributor doesn't try to "fix" it.Each ServerWater+ filter → per-Area chain walk) is sound by inspection; the per-iteration body is byte-identical so any difference is purely loop count.Risk
Underwaterrecording,DistUnder#math, earlyExitsemantics, the surroundingUnderwater = 0reset path — all preserved).W\NextWater) is only set in one place (head-insert at allocation) and read in one place (the GameServer.bb walk). No mutation path; no risk of corruption.ExitinsideWhile...Wendworks the same way in Blitz3D as insideFor...Next(breaks the innermost loop) — verified by successful compile + test.🤖 Generated with Claude Code