Boon overhaul CODE-COMPLETE (mechanic-changers): design forks locked, pre-code review (11 findings folded), post-impl review (incomplete on a session limit — raised findings self-verified, 2 real bugs fixed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
20 KiB
title, date, tags, permalink
| title | date | tags | permalink | ||||||
|---|---|---|---|---|---|---|---|---|---|
| Boon Overhaul (Phase 1.7) — Build Spec | 2026-07-12 |
|
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<Simulate>() 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<Entity> 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):
[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):
public struct ProjectileEffectState : IComponentData {
public byte PierceRemaining, ChainRemaining, Flags; // Flags bit0 = Pull
public FixedList64Bytes<Entity> 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):
public struct DashTrailState : IComponentData {
public uint LastStartTick; // clear the set when DashState.StartTick differs (C7)
public FixedList64Bytes<Entity> 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):
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:
// 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:
// Exactly ONE row per SourceId in BOTH buffers (remove-then-add). Refresh, never stack (C4).
public static void Upsert(DynamicBuffer<StatModifier> mods, DynamicBuffer<TimedModifier> 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): addRefRW<ProjectileEffectState>and changeProjectiletoRefRW(chain rewritesDirection). In the selection scan,continueon any target infx.Hit(beside the self-skip:116-118). On hit: append damage, add target tofx.Hit, then —Pierce>0→ decrement+survive; elseChain>0→ rescan next-nearest living target not infx.Hit, rewriteDirection, decrement+survive; else /fx.Hitfull → destroy. Duplicate theDistanceTravelled>=Rangecheck on the survive branch (destroy if exceeded). Keep exactly oneecb.DestroyEntityper projectile per tick. Seedfxserver-side inAbilityFireSystem(:211-219) from the owner'sBoonEffects. - Fork (
AbilityFireSystem, both worlds,:198-219): spawnForkextra projectiles at ± spread;spawnId = (ownerNetId<<16) | (absoluteFireCount<<4) | forkIndex— deterministic + unique soProjectileClassificationSystempredicts each. Fork count from replicatedBoonEffects(owner has it via SendToOwner). - Knock→Pull: projectile path —
ProjectileDamageSystem.cs:148writesDir = (fx.Flags&1)!=0 ? -proj.Direction : proj.Direction. Melee/cone — addbool pulltoKnockbackUtil.Stamp(negate the away-dir);MeleeComboSystemcleave +AbilityFireSystemcone (:146) pass the attacker's KnockToPull. Boss-immune +HasComponent<KnockbackState>guards preserved. - Dash trail — new server-only
DashTrailDamageSystem[WorldSystemFilter(ServerSimulation)][UpdateInGroup(PredictedSimulationSystemGroup)][UpdateAfter(DashSystem)][UpdateBefore(HealthApplyDamageSystem)]. Per active-window player withBoonEffects.DashTrail: ifDashState.StartTick != DashTrailState.LastStartTick→ clearHit, setLastStartTick. AppendDamageEvent{SourceNetworkId=NetId, SourceTick=NonZero(now)}to living enemies within a radius of the swept dash segment (prevPos→pos), skipping any already inHit. No enemy Position/Health writes. Enemies carry noDashState→ i-frame negation skipped (harmless). - Finisher detonation — inline in
MeleeComboSystem(C8): stashIsFinisher+DetonateonPendingCleave(readBoonEffectsin the player loop:98-102); after the cleave loop, ifc.IsFinisher && c.Detonate, second radius loop over the existingenemyEntitiessnapshot (:173-184) appendingDamageEvent{SourceNetworkId=OwnerId, SourceTick=c.Stamp}. - On-kill —
HealthApplyDamageSystemcaptureskillerNetId(C9) into theDyingstamp (:149). New server-onlyKillRewardSystem[UpdateInGroup(PredictedSimulationSystemGroup)][UpdateAfter(HealthApplyDamageSystem)]: queryRefRW<Dying>.WithAll<EnemyTag>(); forRewarded==0 && KillerNetId>=0, resolve killer by NetId→player map; Siphon → heal killerHealth.Currentclamped toEffectiveCharacterStats.MaxHealthviaComponentLookup<Health>RW (no aliasing — noHealthin the query); Frenzy →TimedModifierUtil.Upsert(mods, timed, FrenzySourceId, (byte)CooldownTicks, (byte)PercentMult, FrenzyCooldownMult, NonZero(now+FrenzyDurationTicks)). SetRewarded=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/*.csvia MCP only. One edit perapply_text_editscall.
- Step A — data & utils:
BoonEffects.cs,ProjectileEffectState.cs,DashTrailState.cs; extendDying.cs; extendBoonDefBlob+Make+BuildDefault(12 rows); addTuning.FrenzySourceId/durations + band-map comment; addTimedModifierUtil.Upsert. Checkpoint: compile clean;StatModifierlayout 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.ApplyKind branch. Checkpoint: compile;BoonApplySystemTests. - Step D —
RunDirectorSystemReturning edge (zero BoonEffects; add TimedModifier strip). Checkpoint: compile. - Step E — combat hooks (one system per edit, compile +
read_consoleafter each): E1ProjectileDamageSystem; E2AbilityFireSystem; E3KnockbackUtil.Stamp(+bool pull, do FIRST if E1/E2 call it); E4DashTrailDamageSystem(new); E5MeleeComboSystem; E6HealthApplyDamageSystem(killer capture); E7KillRewardSystem(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-bakeGameplay.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 ONEStatModifier+ ONETimedModifierat extendedUntilTick; 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 perRoomEpoch.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-stampsUntilTick;KillerNetId=-1→ no reward.DashTrailDamageSystemTests: enemy in path → exactly ONE DamageEvent across the multi-tick window (StartTick-keyed dedup); next dash re-hits.RunDirectorReturning: strips FrenzyStatModifier+TimedModifier+ zeroesBoonEffects.
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
ComponentSystemSortercircular-dependency exception; no Burst ICE. - RoomReward → pick a Kind=1 boon (Piercing Shots) →
BoonEffects.Pierceincremented 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;
StatModifierlength stable (no stacking). - Cross-run: Returning zeroes BoonEffects + strips the Frenzy timed row; a second run's Frenzy behaves fresh.
5. Open risks
- 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.
- Fork spawnId bit budget:
(absoluteFireCount<<4)|forkIndexcaps absoluteFireCount at 12 bits (~4096 shots/run) and forkIndex at 4 bits (≤15 forks). Cap Fork stacks ≤15; verify no classification collision in Play. - 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). - Hit-set capacity (~7):
ProjectileEffectState.Hitoverflow → self-destruct (natural cap);DashTrailState.Hitoverflow → stop-adding (a re-hit) vs drop — pick a policy. - DashTrail dedup is per-player — verify two simultaneous dashers don't clobber one shared set. Dash-trail
SourceNetworkId≥0can trigger Charger whiff-punish scoring (legitimate; note it). - Frenzy tuning: Upsert prevents stacking, but large mult × long duration refreshed every kill = near-permanent low cooldown. Design-tune.
BoonApplySystemis[BurstCompile]— the new Kind branch reads blob bytes +SetComponent<BoonEffects>(Burst-safe). Watchread_consolefor a Burst ICE; editor-restart cure if it de-Bursts.- Warrior vs Ranger gating: pierce/fork/chain benefit only Ranger. Class-mask them Ranger-only (mask=2).
- Knock→Pull on melee/cone can pull an enemy INTO the player. Verify boss-immune + HasComponent guards survive the
pullparam; design-tune.
Related: Iteration_2026-07_CoopHades · Geyser_Build_Spec · Destructible_Cover_Build_Spec · DR-044_Expedition_Redesign_Shipped_Demo_Polish