--- title: Geyser_Build_Spec type: note permalink: 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 `DamageEvent`s (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`. - **`GeyserEruptSystem`** — `ProjectM.Server`, `[WorldSystemFilter(ServerSimulation)]` + `[UpdateInGroup(typeof(SimulationSystemGroup))]`, **NO `[UpdateBefore/After]` edges**, `RequireForUpdate`. Mirror `HazardExplosionSystem.cs:25-27` (`[BurstCompile]` fine — no enums, no lifted `SystemAPI.Query`). Each tick: `TryGetSingleton`; if `!ServerTick.IsValid` → skip. Iterate `(RefRW, LocalTransform, Entity)`: - **Erupt condition — INVERTED from the barrel (H2):** `until = new NetworkTick(g.NextEruptTick)`. If `g.NextEruptTick == 0u || !until.IsValid` → **skip** (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.ValueRW`. - **Never** `DestroyEntity`. ## 3. Client - **New sibling `GeyserTelegraphSystem`** — `ProjectM.Client`, observe-only `SystemBase`, `[WorldSystemFilter(ClientSimulation)]` + `[UpdateInGroup(typeof(PresentationSystemGroup))]` (sibling of `EnemyDangerTelegraphSystem`). `CompleteDependencyBeforeRO()` 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` + `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`; 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** `DamageEvent`s 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.