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>
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 }inProjectM.Simulation. GeyserEruptSystem—ProjectM.Server,[WorldSystemFilter(ServerSimulation)]+[UpdateInGroup(typeof(SimulationSystemGroup))], NO[UpdateBefore/After]edges,RequireForUpdate<Geyser>. MirrorHazardExplosionSystem.cs:25-27([BurstCompile]fine — no enums, no liftedSystemAPI.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). Ifg.NextEruptTick == 0u || !until.IsValid→ skip (unstamped = not-ready; never erupt). Ifuntil.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.GeyserEruptRadiusTuning.GeyserEruptDamage. PlayersHealth>0 && RegionTag==Expedition, XZ within radius; enemiesHealth>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 fromHazardExplosionSystem.cs:57. - Reschedule = now + period, NOT
+=(M3):g.ValueRW.NextEruptTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + Tuning.GeyserPeriodTicks);viaRefRW<Geyser>.ValueRW. - Never
DestroyEntity.
- Erupt condition — INVERTED from the barrel (H2):
3. Client
- New sibling
GeyserTelegraphSystem—ProjectM.Client, observe-onlySystemBase,[WorldSystemFilter(ClientSimulation)]+[UpdateInGroup(typeof(PresentationSystemGroup))](sibling ofEnemyDangerTelegraphSystem).CompleteDependencyBeforeRO<Geyser>()before reading. Read the clientNetworkTime.ServerTick(the predicted ServerTick — matches shipped enemy/HUD countdowns; NOT InterpolationTick). Per-geyser cache keyed byEntity, 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 atGeyserEruptRadiusscaled by(1 - lead/(float)GeyserTelegraphTicks)while0 < lead <= GeyserTelegraphTicks. - Erupt burst — fire on the ServerTick CROSSING, latched (H1): fire when
leadcrosses>0 → <=0(serverTickfirst reaches/exceedsNextEruptTick). Store_lastFired[entity] = g.NextEruptTick; refire ONLY whenNextEruptTickdiffers (theEnemyDangerTelegraphSystem.cs:177-185_strikeBeepedlatch). Plus a "was counting down" arm-guard — only fire if a prior frame observedlead>0for this entity (EnemyDangerTelegraphSystem.cs:139-140). Immune to: the0→stampphantom, relevancy re-entry mid-period (a freshly re-added ghost haslead>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/MakeBurstparticles +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 ofWorldFeedbackSystem(private:203) intoFeedbackFxso 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: duplicateResourceNode.prefab(orBlightClutter.prefab) — NOTCoverRock.prefab(its Environment-layer collider would block the walk-over hazard). Swap authoring →GeyserAuthoring(bakesGeyser+RegionTag{Expedition}); cosmetic vent mesh; NO collider. Ownerless/ interpolatedGhostAuthoringComponent+LinkedEntityGroupcome free. An inheritedHitRadiusis inert (server scans byTuning.GeyserEruptRadius) — no strip needed. GeyserFieldSpawnerAuthoring→ bakedGeyserFieldSpawner { Entity Prefab; int Count }singleton, mirroringClutterFieldSpawnerAuthoring. Author intoGameplay.unity's subscene; assign the prefab.- Seeding in
RoomFieldSystem.OnUpdateInRoom block, sibling of cover/clutter:- Gate on
plan.Biome == RoomBiomeId.Blight(local plan, NOTinfo.CurrentBiome) — matches the cover block'splan.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'sKeepOutFromOrigin=7). - Stamp
RoomTag{room}with the SAMEroomlocal every sibling uses → freeRoomTeardownremoval. - Born-correct spawn-stamp (H2): the block has no
NetworkTimetoday — addTryGetSingleton<NetworkTime>; if!ServerTick.IsValidthis tick, do NOT seed geysers (the once-per-RoomEpochguard retries). Otherwise per instanceecb.SetComponent(e, new Geyser{ NextEruptTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + Tuning.GeyserPeriodTicks) })in the deferred loop → the entity is fully formed before ECB playback /GhostSendSystemserialization → the first snapshot never carries 0.
- Gate on
- Tuning consts in
Simulation/Tuning.csbeside 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):
- Both-sides erupt: living player + living enemy in radius each get exactly one
DamageEvent;SourceNetworkId == -1. - Reschedule: after eruption
NextEruptTickadvances by exactlyGeyserPeriodTicksfrom now, never the0sentinel. - Never destroyed: the geyser survives the eruption (opposite of the barrel destroy-once assertion).
- Unstamped-storm regression (H2): seed a geyser at
serverTick=5000withNextEruptTick=0; assert zeroDamageEvents on the first ticks (the inverted guard skips — no per-tick barrage). - 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
GeyserFieldSpawnerauthored intoGameplay.unitysubscene (load-bearing — unwired = zero geysers, silent). - Modified:
RoomFieldSystem(Blight seed block +NetworkTimespawn-stamp),Tuning.cs(4 consts),FeedbackFx(promoteBuildDisc+ material factory),WorldFeedbackSystem(fuse ring calls the promoted primitive). - Relevancy: free via prefab-baked
RegionTag{Expedition}→ existingRegionRelevancySystemSetIsIrrelevanthides 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.