Files
Project-M/Docs/Vault/03_Design/Geyser_Build_Spec.md
T
kronic f271d5f47f Docs: Geyser Build Spec + session log Part Q + roadmap tick + CLAUDE.md gotcha — Phase 1.5b bundle 3
Blight geyser (bundle 3) shipped review-first. Records the design-review fold
(2 HIGH: no edge-detect on the replicated tick; invert the invalid-guard for a
baked scheduled-tick so a born-0 can't storm) and the new CLAUDE.md tick-sentinel
corollary for baked [GhostField] scheduled-ticks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:53:22 -07:00

11 KiB

title, type, permalink
title type permalink
Geyser_Build_Spec note gamevault/03-design/geyser-build-spec

Blight Geyser — Build Spec (Phase 1.5b bundle 3, design-review-hardened)

Review: wf_900e9965-8f0 (3 lenses — netcode/relevancy · determinism/prediction · reuse/scope — 23 agents, 0 errors, 19 findings → 11 CONFIRMED / 8 refuted; the workflow-agent-quota-failures-look-clean check passed — no agents died). This spec is the fold; it supersedes the pre-review draft. Mirrors the two shipped hazards Exploding_Barrels_Build_Spec + Destructible_Cover_Build_Spec.

Locked (operator, do not revisit): Blight-biome rooms ONLY · PERMANENT (never destroyed; RoomTag teardown is the only removal) · both-sides telegraphed AoE (friendly fire vs living players AND enemies, like the shipped exploding barrels) · also a tactical lure tool.

1. Core mechanism

A Geyser is an ownerless interpolated ghost placed only in Blight rooms. One replicated field — [GhostField] uint NextEruptTick, the absolute server tick of its next eruption. Server-only GeyserEruptSystem (plain SimulationSystemGroup) fires when ServerTick reaches NextEruptTick: radius-gathers living players + living enemies in XZ, appends DamageEvents (drained next tick by HealthApplyDamageSystem, exactly the barrel path), then reschedules NextEruptTick one period ahead of now. NEVER destroys itself. The client telegraph derives the countdown from the absolute NextEruptTick vs its own predicted ServerTick (never an edge on the replicated value) to grow a warning disc, and fires the erupt burst on the tick-crossing, latched once per eruption.

2. Server

  • Component Geyser { [GhostField] uint NextEruptTick } in ProjectM.Simulation.
  • GeyserEruptSystemProjectM.Server, [WorldSystemFilter(ServerSimulation)] + [UpdateInGroup(typeof(SimulationSystemGroup))], NO [UpdateBefore/After] edges, RequireForUpdate<Geyser>. Mirror HazardExplosionSystem.cs:25-27 ([BurstCompile] fine — no enums, no lifted SystemAPI.Query). Each tick: TryGetSingleton<NetworkTime>; if !ServerTick.IsValid → skip. Iterate (RefRW<Geyser>, LocalTransform, Entity):
    • Erupt condition — INVERTED from the barrel (H2): until = new NetworkTick(g.NextEruptTick). If g.NextEruptTick == 0u || !until.IsValidskip (unstamped = not-ready; never erupt). If until.IsNewerThan(serverTick) → not yet, skip. Otherwise erupt.
    • Gather — inline, do NOT extract a helper (M5): duplicate the ~12-line both-sides gather verbatim from HazardExplosionSystem.cs:59-89, parameterized only by this geyser's position + Tuning.GeyserEruptRadius
      • Tuning.GeyserEruptDamage. Players Health>0 && RegionTag==Expedition, XZ within radius; enemies Health>0, XZ within radius. AppendToBuffer(DamageEvent{ Amount=GeyserEruptDamage, SourceNetworkId=-1, SourceTick=stamp }).
    • SourceTick = NOW, not the scheduled tick (M8): uint stamp = TickUtil.NonZero(serverTick.TickIndexForValidTick); — verbatim from HazardExplosionSystem.cs:57.
    • Reschedule = now + period, NOT += (M3): g.ValueRW.NextEruptTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + Tuning.GeyserPeriodTicks); via RefRW<Geyser>.ValueRW.
    • Never DestroyEntity.

3. Client

  • New sibling GeyserTelegraphSystemProjectM.Client, observe-only SystemBase, [WorldSystemFilter(ClientSimulation)] + [UpdateInGroup(typeof(PresentationSystemGroup))] (sibling of EnemyDangerTelegraphSystem). CompleteDependencyBeforeRO<Geyser>() before reading. Read the client NetworkTime.ServerTick (the predicted ServerTick — matches shipped enemy/HUD countdowns; NOT InterpolationTick). Per-geyser cache keyed by Entity, pruned every frame (pruned = silent removal; emit nothing on prune).
  • Disc (absolute-tick derived — the sound half of the original design): guard g.NextEruptTick != 0u && new NetworkTick(g.NextEruptTick).IsValid. int lead = new NetworkTick(g.NextEruptTick).TicksSince(serverTick) (negative once passed); clamp to [0, GeyserTelegraphTicks]. Grow the disc at GeyserEruptRadius scaled by (1 - lead/(float)GeyserTelegraphTicks) while 0 < lead <= GeyserTelegraphTicks.
  • Erupt burst — fire on the ServerTick CROSSING, latched (H1): fire when lead crosses >0 → <=0 (serverTick first reaches/exceeds NextEruptTick). Store _lastFired[entity] = g.NextEruptTick; refire ONLY when NextEruptTick differs (the EnemyDangerTelegraphSystem.cs:177-185 _strikeBeeped latch). Plus a "was counting down" arm-guard — only fire if a prior frame observed lead>0 for this entity (EnemyDangerTelegraphSystem.cs:139-140). Immune to: the 0→stamp phantom, relevancy re-entry mid-period (a freshly re-added ghost has lead>0 → no crossing on seed), and snapshot loss (absolute tick). Co-locates the burst with the disc completion on the ServerTick timeline (also fixes the boom-trails-disc split).
  • Erupt VFX — reuse shipped statics (no new plumbing): FeedbackFx.EmitTinted/MakeBurst particles + DynamicLightSystem.RequestFlash + ScorchDecalSystem.RequestScorch(pos, GeyserEruptRadius) (bundle 2, documented "Reusable by the future Blight-geyser hazard").
  • Promote the disc primitive (M6): move BuildDisc (+ a disc/ring material factory) out of WorldFeedbackSystem (private :203) into FeedbackFx so the barrel fuse ring and the geyser telegraph share ONE primitive. Do NOT re-implement a second private disc-builder.

4. Seeding / authoring

  • Ghost prefab Geyser.prefab: duplicate ResourceNode.prefab (or BlightClutter.prefab) — NOT CoverRock.prefab (its Environment-layer collider would block the walk-over hazard). Swap authoring → GeyserAuthoring (bakes Geyser + RegionTag{Expedition}); cosmetic vent mesh; NO collider. Ownerless/ interpolated GhostAuthoringComponent + LinkedEntityGroup come free. An inherited HitRadius is inert (server scans by Tuning.GeyserEruptRadius) — no strip needed.
  • GeyserFieldSpawnerAuthoring → baked GeyserFieldSpawner { Entity Prefab; int Count } singleton, mirroring ClutterFieldSpawnerAuthoring. Author into Gameplay.unity's subscene; assign the prefab.
  • Seeding in RoomFieldSystem.OnUpdate InRoom block, sibling of cover/clutter:
    • Gate on plan.Biome == RoomBiomeId.Blight (local plan, NOT info.CurrentBiome) — matches the cover block's plan.RoomType != RoomTypeId.Boss. RoomBiomeId.Blight == 3.
    • Never Boss rooms — an explicit, commented design choice (geyser has no collider → NOT the cover depenetration concern; it's a boss-fight decision).
    • TryGetSingleton<GeyserFieldSpawner> + Prefab != Entity.Null (optional singleton; absent → skip).
    • Distinct hash sub-stream 0x6E7; keep-out ring around the room origin (like cover's KeepOutFromOrigin=7).
    • Stamp RoomTag{room} with the SAME room local every sibling uses → free RoomTeardown removal.
    • Born-correct spawn-stamp (H2): the block has no NetworkTime today — add TryGetSingleton<NetworkTime>; if !ServerTick.IsValid this tick, do NOT seed geysers (the once-per-RoomEpoch guard retries). Otherwise per instance ecb.SetComponent(e, new Geyser{ NextEruptTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + Tuning.GeyserPeriodTicks) }) in the deferred loop → the entity is fully formed before ECB playback / GhostSendSystem serialization → the first snapshot never carries 0.
  • Tuning consts in Simulation/Tuning.cs beside the barrel block: GeyserPeriodTicks = 300 (5s@60), GeyserTelegraphTicks = 78 (~1.3s), GeyserEruptRadius = 3.0f, GeyserEruptDamage = 22. Single-source only the constants (no shared gather helper).

5. Tests

Assets/_Project/Tests/EditMode/GeyserEruptSystemTests.cs, plain-Entities, mirroring HazardExplosionSystemTests.cs (reuse MakeWorld/MakePlayer/MakeEnemy):

  1. Both-sides erupt: living player + living enemy in radius each get exactly one DamageEvent; SourceNetworkId == -1.
  2. Reschedule: after eruption NextEruptTick advances by exactly GeyserPeriodTicks from now, never the 0 sentinel.
  3. Never destroyed: the geyser survives the eruption (opposite of the barrel destroy-once assertion).
  4. Unstamped-storm regression (H2): seed a geyser at serverTick=5000 with NextEruptTick=0; assert zero DamageEvents on the first ticks (the inverted guard skips — no per-tick barrage).
  5. Out-of-radius / dead filters: dead or out-of-radius get no DamageEvent.

6. Wire / bake classification

  • New ghost type + prefab Geyser.prefab → genuinely NEW ghost hash (NOT a byte re-mean). ONE new [GhostField] (Geyser.NextEruptTick).
  • New systems: GeyserEruptSystem (server) + GeyserTelegraphSystem (client).
  • New baked singleton GeyserFieldSpawner authored into Gameplay.unity subscene (load-bearing — unwired = zero geysers, silent).
  • Modified: RoomFieldSystem (Blight seed block + NetworkTime spawn-stamp), Tuning.cs (4 consts), FeedbackFx (promote BuildDisc + material factory), WorldFeedbackSystem (fuse ring calls the promoted primitive).
  • Relevancy: free via prefab-baked RegionTag{Expedition} → existing RegionRelevancySystem SetIsIrrelevant hides it cross-region. No relevancy-system change.
  • Ordering: edge-free server system → no new sort constraint, no Play-only cycle (confirmed).
  • Re-bake: new prefab + subscene spawner + authoring defaults; full asmdef recompile for the new types.

7. Changes the review FORCED vs the original design

HIGH — H1 erupt VFX must NOT edge-detect the NextEruptTick jump (phantom-fires on 0→stamp + relevancy re-entry + snapshot loss) → absolute-tick TicksSince disc + latched >0→<=0 crossing + arm-guard (mirror EnemyDangerTelegraphSystem; AttackWindup.cs:12 "absolute, not an edge"). Also fixes the boom-trails-disc timeline split. · H2 INVERT the barrel's invalid-tick guard (unstamped 0 → SKIP, never erupt) + add NetworkTime to RoomFieldSystem and stamp NextEruptTick born-correct at spawn (else a baked-0 geyser erupts every tick = a party-wiping barrage).

MED — M3 reschedule = now + period, not += (catch-up storm; every in-repo sentinel is = now + delay). · M5 do NOT extract a shared HazardDamage gather helper (SystemAPI.Query can't be lifted to a static; verbatim-copy is the shipped convention; Burst-ICE risk) — duplicate inline. · M6 promote BuildDisc into FeedbackFx. · M7 gate on plan.Biome, not info.CurrentBiome. · M8 stamp SourceTick at NOW, not the scheduled eruptTick (tick-batching mis-negates dash i-frames).

LOW — duplicate ResourceNode/BlightClutter (no collider), not CoverRock. · add GeyserEruptSystemTests.

Confirmed correct, no change: plain-SimulationSystemGroup placement · next-tick DamageEvent drain · single-apply · SourceNetworkId=-1 attribution · dash-i-frame negation · edge-free ordering · RegionTag{Expedition} relevancy scoping.