--- title: Boon Overhaul (Phase 1.7) — Build Spec date: 2026-07-12 tags: [design, build-spec, boons, phase-1.7, coop-hades, netcode] permalink: gamevault/03-design/boon-overhaul-build-spec --- # Phase 1.7 — Boon Overhaul: BUILD SPEC (verified, implementation-ready) Unity 6.5 DOTS + Netcode for Entities. Server-authoritative, input-only clients, client prediction. Folds in every confirmed adversarial fix from the pre-code design review (`wf_c4bdd60d-7b2`, 27 agents / 0 errors, 23 findings raised → 11 confirmed). Operator-locked scope: ability-swap boons DEFERRED to Phase 3; ~8 mechanic + ~4 stat; synergy = tags for dedup + dominated-offer protection + light build-bias (NO synergy bonuses). Line refs are to files as read at HEAD `6dcd8a243`. --- ## 0. Corrections folded in (design deltas vs the draft) | # | Draft claim | Verified correction | |---|---|---| | C1 | Put `PierceRemaining/ChainRemaining/EffectFlags` on `Projectile`; "NO ghost-hash change". | **FALSE.** Adding any field changes `StableTypeHash → SerializerHash → ghost TypeHash` (Netcode 6.5). **Put them on a new server-only `ProjectileEffectState` component** (mirrors `KnockbackState`). `Projectile`'s ghost hash stays **frozen** — truly zero projectile re-bake. | | C2 | `BoonEffects` uses `OwnerSendType.All` "so the owner rolls it back". | Rollback comes from the **`[GhostField]`s**, not `All`. Reads are owner-prediction (`AbilityFireSystem` `.WithAll()` excludes interpolated remotes) or server-only. **Use `SendToOwnerType.SendToOwner`**, matching `BoonOffer.cs:16`. | | C3 | Frenzy uses a "dedicated `FrenzySourceId` in the boon band". | The pick counter walks the band bottom-up (`Base + counter%Span`). A low fixed id **collides** and `RemoveBySourceId` strips ALL matches. **Pin `FrenzySourceId = BoonSourceIdBase + BoonSourceIdSpan - 1` (0x00B0FFFF)** — unreachable by the counter, still cleared free by the Returning range-strip. | | C4 | "Refresh a single timed StatModifier" (Frenzy). | Must be an **UPSERT** across BOTH the `StatModifier` and server-only `TimedModifier` buffers; no upsert helper exists. **Add `TimedModifierUtil.Upsert`.** A blind `Add` stacks rows → compounding `-CooldownTicks`. | | C5 | Returning strip clears boons. | Strips **only `StatModifier`** rows (`RunDirectorSystem.cs:392-401`); the server-only `TimedModifier` buffer is untouched → a stale Frenzy timed row survives a run boundary. **Add a `TimedModifier` strip on the Returning edge.** | | C6 | Pierce/chain re-hit guard = single `LastHitEntity`. | Insufficient: selection picks earliest-by-distance with no last-hit exclusion (`ProjectileDamageSystem.cs:108-134`). **Use a per-projectile `FixedList64Bytes` history-set, excluded DURING selection** (beside the self-skip `:116-118`). Overflow → destroy (natural cap). | | C7 | Dash-trail dedup "store on DashState? or a transient set". | `DashState` has no reliable clear edge on a release server. **Key dedup to `DashState.StartTick`** (= `TickUtil.NonZero(now)` every dash) in a **per-player** server-only `DashTrailState`. | | C8 | Finisher detonation. | Implement **inline** in `MeleeComboSystem` over the already-gathered enemy snapshot (`:173-184`); no new system/helper. `IsFinisher` is NOT on `PendingCleave` and the resolution block has no `BoonEffects` access → **read `BoonEffects.FinisherDetonate` in the player loop (`:98-102`) and stash BOTH `IsFinisher` + the flag on `PendingCleave`.** `SourceNetworkId = OwnerId` (not −1), `SourceTick = c.Stamp`. | | C9 | Killer capture. | Must read `SourceNetworkId>=0` **inside the drain loop, before `dmg.Clear()` (`:117`)** — the `Dying` stamp is after the clear (`:149`). Capture a per-victim `int killerNetId=-1`, updated after the negation `continue` (`:98`); "last player-sourced event" is the only rule the summed-damage structure supports. | | C10 | `PickBoons` owned-state breaks determinism. | **Non-issue** — server-only, single draw per `RoomEpoch` (`BoonOfferSystem.cs:53` latch), client never re-runs it. Proceed; just **update stale determinism doc-comments** (`BoonOfferSystem.cs:68`, `BoonCatalog.cs:48`) to `(seed,room,player,ownedState-at-draw)`. | --- ## 1. Final design ### 1.1 New / extended data **`Simulation/Combat/BoonEffects.cs`** (new, replicated, on the player): ```csharp [GhostComponent(OwnerSendType = SendToOwnerType.SendToOwner)] // C2 — matches BoonOffer.cs:16 public struct BoonEffects : IComponentData { [GhostField] public byte Pierce; // extra projectile hits survived (stacks) [GhostField] public byte Fork; // extra spread projectiles per shot (stacks) [GhostField] public byte Chain; // chain-to-next targets on hit (stacks) [GhostField] public byte Flags; // BoonFlag bits } public static class BoonFlag { public const byte DashTrail=1, FinisherDetonate=2, KnockToPull=4, Siphon=8, Frenzy=16; } public static class BoonEffectKind { public const byte None=0,Pierce=1,Fork=2,Chain=3,DashTrail=4,FinisherDetonate=5,KnockToPull=6,Siphon=7,Frenzy=8; } // bytes — Burst-safe, never an enum compared in Burst ``` Baked INERT (all 0) on the player prefab. Rollback-correct via the `[GhostField]`s (owner is the sole predicting client). Zeroed on the Returning edge. **`Simulation/Combat/ProjectileEffectState.cs`** (new, **server-only, NOT a `[GhostField]`**, on the projectile — mirrors `KnockbackState`): ```csharp public struct ProjectileEffectState : IComponentData { public byte PierceRemaining, ChainRemaining, Flags; // Flags bit0 = Pull public FixedList64Bytes Hit; // ~7 entries — re-hit history (C6) } ``` Baked inert on the projectile prefab (KnockbackState precedent → **no projectile ghost re-bake**). Seeded server-side at spawn from the owner's `BoonEffects`. **`Simulation/Player/DashTrailState.cs`** (new, server-only, plain, on the player): ```csharp public struct DashTrailState : IComponentData { public uint LastStartTick; // clear the set when DashState.StartTick differs (C7) public FixedList64Bytes Hit; } ``` Baked inert on the player prefab. Non-replicated → no ghost-hash impact; piggybacks the BoonEffects re-bake. **`Simulation/Combat/Dying.cs`** — extend (server-only, not replicated → no wire): ```csharp public struct Dying : IComponentData { public uint UntilTick; public int KillerNetId; public byte Rewarded; } ``` **`Simulation/Combat/BoonCatalog.cs`** — extend `BoonDefBlob` (blob = config, not wire) with `byte Kind` (0=Stat,1=Effect), `byte EffectKind` (`BoonEffectKind` byte), `byte Family` (0=none,1=projectile,2=melee,3=mobility,4=onkill,5=stat-dmg,6=stat-hp,7=stat-speed,8=stat-cd). Update `Make(...)` and `BuildDefault()` to the 12-entry table (§1.6). `Value` on `Kind=1` = the stack delta (usually 1) for Pierce/Fork/Chain, ignored for flag effects. **`Simulation/Tuning.cs`** — after `BoonSourceIdSpan`, add + extend the band-map comment reserving the top slot: ```csharp // TOP-of-band, disjoint-by-construction from the bottom-up per-pick counter (Base+counter%Span); // cleared free by the Returning RemoveBySourceIdRange. Reserved for the single Frenzy timed row. public const uint FrenzySourceId = BoonSourceIdBase + BoonSourceIdSpan - 1u; // 0x00B0FFFF public const int FrenzyDurationTicks = 240; // ~4s @60hz (tune) public const float FrenzyCooldownMult = -0.30f; // PercentMult on CooldownTicks (tune) ``` **`Simulation/Combat/TimedModifier.cs`** — add to `TimedModifierUtil`: ```csharp // Exactly ONE row per SourceId in BOTH buffers (remove-then-add). Refresh, never stack (C4). public static void Upsert(DynamicBuffer mods, DynamicBuffer timed, uint sourceId, byte target, byte op, float value, uint untilTick) { RemoveBySourceId(mods, sourceId); for (int j = timed.Length-1; j>=0; j--) if (timed[j].SourceId==sourceId) timed.RemoveAtSwapBack(j); mods.Add(new StatModifier { Target=target, Op=op, Value=value, SourceId=sourceId }); timed.Add(new TimedModifier { SourceId=sourceId, UntilTick=untilTick }); } ``` ### 1.2 Offer improvements — `BoonMath.PickBoons` (`BoonCatalog.cs:61`) New param: the picking player's `BoonEffects` (owned state). (a) **Dedup:** exclude non-stacking owned FLAG effects (DashTrail/FinisherDetonate/KnockToPull/Siphon/Frenzy already owned); still re-offer stacking Pierce/Fork/Chain. (b) **Dominated-offer protection:** reject a candidate whose `Family` collides with an already-picked option in THIS deal. (c) **Light build-bias:** ×~1.5 draw weight (integer math) when a candidate's `Family` matches an owned effect's family. Integer-hash only, no RNG state. `BoonOfferSystem.cs:70` passes the player's `BoonEffects`. Update the determinism comments (`BoonOfferSystem.cs:68`, `BoonCatalog.cs:48`) to `(seed,room,player,ownedState-at-draw)`; keep the single-`OfferedRoomEpoch`-latch as the sole draw site. ### 1.3 Apply branch — `BoonApplySystem.Apply` (`BoonApplySystem.cs:113`) `Kind==0` → append `StatModifier` (today). `Kind==1` → mutate the player's `BoonEffects` (SetComponent — non-structural, baked present): `Pierce/Fork/Chain += (byte)Value` for stack kinds, else `Flags |= bit`. Bytes only (Burst-safe; system is `[BurstCompile]`). ### 1.4 Combat hooks - **Pierce/Chain** (`ProjectileDamageSystem`, server-only, `:108-161`): add `RefRW` and change `Projectile` to `RefRW` (chain rewrites `Direction`). In the selection scan, `continue` on any target in `fx.Hit` (beside the self-skip `:116-118`). On hit: append damage, add target to `fx.Hit`, then — `Pierce>0` → decrement+survive; else `Chain>0` → rescan next-nearest living target **not in `fx.Hit`**, rewrite `Direction`, decrement+survive; else / `fx.Hit` full → destroy. **Duplicate the `DistanceTravelled>=Range` check on the survive branch** (destroy if exceeded). Keep **exactly one `ecb.DestroyEntity` per projectile per tick**. Seed `fx` server-side in `AbilityFireSystem` (`:211-219`) from the owner's `BoonEffects`. - **Fork** (`AbilityFireSystem`, both worlds, `:198-219`): spawn `Fork` extra projectiles at ± spread; `spawnId = (ownerNetId<<16) | (absoluteFireCount<<4) | forkIndex` — deterministic + unique so `ProjectileClassificationSystem` predicts each. Fork count from replicated `BoonEffects` (owner has it via SendToOwner). - **Knock→Pull:** projectile path — `ProjectileDamageSystem.cs:148` writes `Dir = (fx.Flags&1)!=0 ? -proj.Direction : proj.Direction`. Melee/cone — add `bool pull` to `KnockbackUtil.Stamp` (negate the away-dir); `MeleeComboSystem` cleave + `AbilityFireSystem` cone (`:146`) pass the attacker's KnockToPull. Boss-immune + `HasComponent` guards preserved. - **Dash trail** — new server-only `DashTrailDamageSystem` `[WorldSystemFilter(ServerSimulation)][UpdateInGroup(PredictedSimulationSystemGroup)][UpdateAfter(DashSystem)][UpdateBefore(HealthApplyDamageSystem)]`. Per active-window player with `BoonEffects.DashTrail`: if `DashState.StartTick != DashTrailState.LastStartTick` → clear `Hit`, set `LastStartTick`. Append `DamageEvent{SourceNetworkId=NetId, SourceTick=NonZero(now)}` to living enemies within a radius of the swept dash segment (prevPos→pos), skipping any already in `Hit`. No enemy Position/Health writes. Enemies carry no `DashState` → i-frame negation skipped (harmless). - **Finisher detonation** — inline in `MeleeComboSystem` (C8): stash `IsFinisher` + `Detonate` on `PendingCleave` (read `BoonEffects` in the player loop `:98-102`); after the cleave loop, if `c.IsFinisher && c.Detonate`, second radius loop over the existing `enemyEntities` snapshot (`:173-184`) appending `DamageEvent{SourceNetworkId=OwnerId, SourceTick=c.Stamp}`. - **On-kill** — `HealthApplyDamageSystem` captures `killerNetId` (C9) into the `Dying` stamp (`:149`). New server-only `KillRewardSystem` `[UpdateInGroup(PredictedSimulationSystemGroup)][UpdateAfter(HealthApplyDamageSystem)]`: query `RefRW` `.WithAll()`; for `Rewarded==0 && KillerNetId>=0`, resolve killer by NetId→player map; **Siphon** → heal killer `Health.Current` clamped to `EffectiveCharacterStats.MaxHealth` via `ComponentLookup` RW (no aliasing — no `Health` in the query); **Frenzy** → `TimedModifierUtil.Upsert(mods, timed, FrenzySourceId, (byte)CooldownTicks, (byte)PercentMult, FrenzyCooldownMult, NonZero(now+FrenzyDurationTicks))`. Set `Rewarded=1` (idempotent value write). ### 1.5 Returning edge — `RunDirectorSystem.cs:392-401` Add `BoonEffects` + `TimedModifier` to the query; zero `BoonEffects`; `TimedModifierUtil.RemoveBySourceId(timed, Tuning.FrenzySourceId)` (C5) alongside the existing `StatModifier` range-strips. Idempotent every Returning tick. ### 1.6 Catalog (12): 8 mechanic + 4 stat 1 Piercing Shots (Ranger·projectile·pierce+1) · 2 Split Shot (Ranger·projectile·fork+1) · 3 Ricochet (Ranger·projectile·chain+1) · 4 Detonating Finisher (Warrior·melee·FinisherDetonate) · 5 Blade Dash (both·mobility·DashTrail) · 6 Gravity Pull (both·melee/proj·KnockToPull) · 7 Siphon (both·onkill·Siphon) · 8 Frenzy (both·onkill·Frenzy) · 9 Executioner (both·stat-dmg·+50% Damage) · 10 Titan's Vigor (both·stat-hp·+60 MaxHealth) · 11 Fleet Foot (both·stat-speed·+18% MoveSpeed) · 12 Berserker's Pace (both·stat-cd·−25% CooldownTicks). `ClassMask` bit0=Warrior, bit1=Ranger — projectile boons Ranger-only (mask 2). --- ## 2. Build order (each step: MCP edit → `refresh_unity scope=scripts` → `read_console`) > **Component before any system that references it.** Edit `Assets/*.cs` via MCP only. One edit per `apply_text_edits` call. - **Step A — data & utils:** `BoonEffects.cs`, `ProjectileEffectState.cs`, `DashTrailState.cs`; extend `Dying.cs`; extend `BoonDefBlob`+`Make`+`BuildDefault` (12 rows); add `Tuning.FrenzySourceId`/durations + band-map comment; add `TimedModifierUtil.Upsert`. **Checkpoint:** compile clean; `StatModifier` layout UNTOUCHED; all bytes (no enum-in-Burst). - **Step B — `BoonMath.PickBoons` + `BoonOfferSystem`:** owned-state param, dedup/dominated/bias; update determinism comments. **Checkpoint:** compile; `BoonOfferTests`. - **Step C — `BoonApplySystem.Apply` Kind branch. Checkpoint:** compile; `BoonApplySystemTests`. - **Step D — `RunDirectorSystem` Returning edge** (zero BoonEffects; add TimedModifier strip). **Checkpoint:** compile. - **Step E — combat hooks (one system per edit, compile + `read_console` after each):** E1 `ProjectileDamageSystem`; E2 `AbilityFireSystem`; E3 `KnockbackUtil.Stamp` (+`bool pull`, do FIRST if E1/E2 call it); E4 `DashTrailDamageSystem` (new); E5 `MeleeComboSystem`; E6 `HealthApplyDamageSystem` (killer capture); E7 `KillRewardSystem` (new). After E4/E7 the predicted group has new ordering edges (`Dash→DashTrail→Health`, `Health→KillReward`) — acyclic; **cycle is invisible to EditMode → MUST Play-validate at world creation.** - **Step F — authoring/bake (no asset edits in Play):** `PlayerAuthoring` +`BoonEffects`+`DashTrailState` (default); projectile prefab +`ProjectileEffectState` (default); re-bake the 12-row blob; re-bake `Gameplay.unity`. Both worlds recompile+rebake together → identical new player-ghost hash. - **Step G — verification (§4).** --- ## 3. Wire / bake churn classification **Ghost-hash CHANGE → player ghost re-bakes ONCE (front-loaded, acceptable):** `BoonEffects` (new `GhostComponent` on the player). A stale un-rebaked Gameplay subscene or a mixed-version peer will be **REFUSED at handshake / throw the runtime hash error** — inherent to adding any GhostComponent. **Local re-bake, NO ghost-hash / NO wire change:** `DashTrailState` on the player (plain); `ProjectileEffectState` on the projectile prefab (plain, server-only) — **`Projectile`'s ghost hash stays FROZEN** (C1 fix); `BoonDefBlob` extension + 12-row table (config blob). **No bake, no wire:** `Dying` +`KillerNetId`/`Rewarded` (server-only, runtime-added); `StatModifier` UNCHANGED (Frenzy reuses the byte-identical row via Upsert); `TimedModifier` layout unchanged; **RPC collection UNCHANGED** (`BoonPickRequest`/`BoonOffer` untouched). --- ## 4. Verification **EditMode (plain-Entities, public API):** - `TimedModifierUtilTests`: Upsert N× → exactly ONE `StatModifier` + ONE `TimedModifier` at extended `UntilTick`; Upsert→RemoveBySourceId → zero in both. - `BoonMathTests`/`BoonOfferTests` (extend): family-dedup; owned-flag exclude; stacking effect re-offer; build-bias weight boost; determinism `(seed,room,player,ownedState)`; single draw per `RoomEpoch`. - `BoonApplySystemTests`: Kind=0 → StatModifier appended (unchanged); Kind=1 → BoonEffects mutated, StatModifier buffer untouched. - `ProjectileDamageSystemTests`: (1) fast projectile + large target + pierce=1 → exactly ONE DamageEvent per target, survives once, expires at Range; (2) chain=2, A nearest to B → A not re-hit after B; (3) never double-destroyed per tick. - `KillRewardSystemTests`: Siphon heals (clamped, once); Frenzy Upsert = one+one row, re-kill re-stamps `UntilTick`; `KillerNetId=-1` → no reward. - `DashTrailDamageSystemTests`: enemy in path → exactly ONE DamageEvent across the multi-tick window (StartTick-keyed dedup); next dash re-hits. - `RunDirector` Returning: strips Frenzy `StatModifier` + `TimedModifier` + zeroes `BoonEffects`. **Play-smoke (headless — expedition-run recipe: server `PlayerReady=1` from clean Staging, guardian delegate + screenshots in the SAME `execute_code`):** - World creation: no "different hash on the client" error; no `ComponentSystemSorter` circular-dependency exception; no Burst ICE. - RoomReward → pick a Kind=1 boon (Piercing Shots) → `BoonEffects.Pierce` incremented on BOTH server and owner client. - Fire: pierce survives ≥1 hit + damages ≥2 enemies; fork spawns N predicted projectiles (no predicted-spawn desync warning); chain retargets; pull drags an enemy toward the player. - Kill streak: Siphon heals the killer; Frenzy drops cooldown; `StatModifier` length stable (no stacking). - Cross-run: Returning zeroes BoonEffects + strips the Frenzy timed row; a second run's Frenzy behaves fresh. --- ## 5. Open risks 1. **Ghost-hash change is real (§3).** Force a full Gameplay re-bake at Step F; never run against a stale build. One-time/front-loaded. 2. **Fork spawnId bit budget:** `(absoluteFireCount<<4)|forkIndex` caps absoluteFireCount at 12 bits (~4096 shots/run) and forkIndex at 4 bits (≤15 forks). Cap Fork stacks ≤15; verify no classification collision in Play. 3. **Chain rewrites `Projectile.Direction`** — relies on the existing auto-target precedent (server already rewrites Direction). Owner's predicted projectile may visibly snap on a chain tick (cosmetic). 4. **Hit-set capacity (~7):** `ProjectileEffectState.Hit` overflow → self-destruct (natural cap); `DashTrailState.Hit` overflow → stop-adding (a re-hit) vs drop — pick a policy. 5. **DashTrail dedup is per-player** — verify two simultaneous dashers don't clobber one shared set. Dash-trail `SourceNetworkId≥0` can trigger Charger whiff-punish scoring (legitimate; note it). 6. **Frenzy tuning:** Upsert prevents stacking, but large mult × long duration refreshed every kill = near-permanent low cooldown. Design-tune. 7. **`BoonApplySystem` is `[BurstCompile]`** — the new Kind branch reads blob bytes + `SetComponent` (Burst-safe). Watch `read_console` for a Burst ICE; editor-restart cure if it de-Bursts. 8. **Warrior vs Ranger gating:** pierce/fork/chain benefit only Ranger. Class-mask them Ranger-only (mask=2). 9. **Knock→Pull on melee/cone** can pull an enemy INTO the player. Verify boss-immune + HasComponent guards survive the `pull` param; design-tune. Related: [[Iteration_2026-07_CoopHades]] · [[Geyser_Build_Spec]] · [[Destructible_Cover_Build_Spec]] · [[DR-044_Expedition_Redesign_Shipped_Demo_Polish]]