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):
publicstaticclassBoonEffectKind{publicconstbyteNone=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`):
publicFixedList64Bytes<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):
```csharp
publicstructDashTrailState:IComponentData{
publicuintLastStartTick;// clear the set when DashState.StartTick differs (C7)
publicFixedList64Bytes<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):
**`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.
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.
`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<ProjectileEffectState>` 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).
- **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<Dying>``.WithAll<EnemyTag>()`; for `Rewarded==0 && KillerNetId>=0`, resolve killer by NetId→player map; **Siphon** → heal killer `Health.Current` clamped to `EffectiveCharacterStats.MaxHealth` via `ComponentLookup<Health>` 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).
Add `BoonEffects` + `TimedModifier` to the query; zero `BoonEffects`; `TimedModifierUtil.RemoveBySourceId(timed, Tuning.FrenzySourceId)` (C5) alongside the existing `StatModifier` range-strips. Idempotent every Returning tick.
- **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`.
-`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.
**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<BoonEffects>` (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.
baked INERT on the player, zeroed on the Returning edge.** Rollback-correctness comes from the GhostFields, NOT the
send type (owner is the sole predictor; needed so the owner predict-spawns the right Fork/Pierce projectiles).
A pick MUTATES it (non-structural, baked-present) — never AddComponent.
2.**Per-projectile pierce/chain state lives on a SEPARATE server-only `ProjectileEffectState`, never on `Projectile`.**
Adding ANY field to the ghost `Projectile` changes its StableTypeHash→serializer hash = a projectile ghost re-bake;
a separate non-ghost component keeps it frozen (the `KnockbackState` precedent).
3.**Pierce/chain re-hit guard = a per-projectile hit-set excluded DURING target selection** (not post-filtered), and
**exactly ONE `ecb.DestroyEntity` per projectile per tick** across the hit / survive+range / no-hit branches.
4.**Fork = extra predicted projectiles at a spread**, each with a UNIQUE deterministic spawnId
`(netId<<16)|((fireCount&0xFFF)<<4)|(forkIndex&0xF)`. **forkIndex is 4 bits → Fork MUST be capped ≤15** (shipped cap 8);
an uncapped fork wraps forkIndex → spawnId collision → classification mis-match. (Open risk: fireCount is 12 bits →
wraps at 4096 shots/run.)
5.**On-kill boons (Siphon/Frenzy) live in a SEPARATE `KillRewardSystem`, not in `HealthApplyDamageSystem`** — healing the
killer needs RW `Health` which would alias that system's `RefRW<Health>` victim query. The kill edge stamps
`Dying.KillerNetId` (last player-sourced hit, captured BEFORE `dmg.Clear()`) + a `Rewarded` idempotency latch.
6. **A run-scoped timed buff (Frenzy) is `Upsert`-ed (refresh, never stack) on a FIXED SourceId pinned to the TOP of the
boon band** (`BoonSourceIdBase+Span-1`, unreachable by the bottom-up pick counter) and stripped from BOTH the
`StatModifier` AND `TimedModifier` buffers on Returning (the range-strip only covers StatModifier).
7.**`BoonMath.PickBoons` takes the owner's `BoonEffects`** for dedup (skip owned non-stacking flags) + dominated-offer
protection (no two same-`Family` in a deal) + light ×1.5 build-bias — integer-hash only, drawn ONCE per RoomEpoch (the
latch), so feeding owned-state stays deterministic per (seed,room,player,owned-at-draw).
8.**Projectile boons (pierce/fork/chain) are Ranger-only (ClassMask=2)** — the Warrior's Fire is a cone, not a projectile.
9.**`SystemAPI.Query` caps at 7 type args** → reading `BoonEffects` in the already-7-wide `AbilityFireSystem`/`MeleeComboSystem`
player loops goes through a `ComponentLookup`, not an 8th query type.
## Deferred (open)
Ability-swap boons (→ Phase 3, with a `PlayerClass` GhostField if the client class-derivation coupling matters); a
client build-display HUD for active boons; DashTrail as a swept segment (v1 uses a per-tick radius — tunnel-safe at the
current dash step); fireCount 12-bit spawnId budget.
## Process note
Post-impl review (`wf_d2a0a673-af5`) had **4 verify agents die on a session limit** — an INCOMPLETE review masquerading as
near-clean. The raised-but-unverified findings were self-verified against the code; two were real bugs (fork cap, dash
overflow) the Build Spec §5 open-risks had predicted. Reinforces [[the failures-list check]] + the value of writing
open-risks into the spec so a truncated review still has a checklist.
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.