--- date: 2026-06-09 type: session tags: - session - combat - mc-1 - build-spec - netcode permalink: gamevault/07-sessions/2026/2026-06-09-mc1-build-spec --- # MC-1 Build Spec (from the mandatory pre-code design review, 2026-06-09) > The code-grounded implementation spec for MC-1 (dash + Charger duel). Produced by the mandatory adversarial design review (netcode/determinism · reuse/scope · feel-feasibility, all go-with-changes). Drives the build. Roadmap: [[Path_to_Fun]] (MC-1); locks: [[DR-029_Path_A_Fork_Locks]]. # VERDICT: go-with-changes ## IMPLEMENTATION SPEC # MC-1 Implementation Spec (verified against code; ground-truth confirmed) All three review lenses verified the ground-truth against the actual files. Verdict **go-with-changes**: the architecture is sound and idiom-correct; the changes are precision pins (half-open negation window, per-site SourceTick clock + NonZero, idempotent DashSystem split, component-presence Charger discriminator). One material correction confirmed in code below. ## CONFIRMED CORRECTION — the dash "blink" needs NO CharacterProcessor edit (drops R3 from MC-1) `CharacterProcessor.HandleVelocityControl` (CharacterProcessor.cs:105-121) reads `characterComponent.GroundedMovementSharpness` **as a RW ref** and lerps `characterBody.RelativeVelocity` toward `CharacterControl.MoveVelocity` via `StandardGroundMove_Interpolated`. `CharacterComponent` is plain RW `IComponentData`. So a predicted `DashSystem` raising `GroundedMovementSharpness` to ~200 for the dash window (and restoring 15 after) produces the snap with **zero edit to the Bursted processor** — fully headless, no focused-editor Burst restart. The Tuning-knob surface itself names this as the fix ("Dash movement sharpness ~200, the GroundedMovementSharpness override fix"). **DEFAULT = sharpness-override.** The direct `RelativeVelocity` write inside the processor is kept ONLY as a documented fallback if Play shows the lerp-to-200 still visibly ramps — and only THAT fallback carries the Burst-restart. This means MC-1's only focused-editor step is the `.inputactions` wrapper regen + Play-validation. --- ## 1. Components ### EDIT `DamageEvent` (Simulation/Combat/DamageEvent.cs) — currently exactly `{float Amount; int SourceNetworkId}`, IBufferElementData, server-side, NOT replicated Add: `public uint SourceTick;` — the raw ServerTick at which the hit LOGICALLY LANDS (the appending tick). **Confirmed zero ghost-hash impact** (the buffer is non-replicated; its doc-comment says so). Update the doc-comment to note SourceTick = the tick the strike was authored, used by the dash i-frame negation. ### EDIT `PlayerInput` (Simulation/Player/PlayerInput.cs) Add after `Fire`: `[GhostField] public InputEvent Dash;` (verbatim Fire twin). In `ToFixedString()` append `s.Append(';'); s.Append(Dash.Count);`. **Churn class: command-hash change** (InputBufferData serializer + command-collection hash) — both peers rebuild; NOT a ghost-prefab re-bake. ### NEW `DashState` (Simulation/Player/DashState.cs) — predicted, NON-replicated, derived each tick (clone KnockbackState SHAPE only) ``` public struct DashState : IComponentData { public float2 Dir; // planar XZ dash heading, captured at dash-start public uint StartTick; // raw ServerTick at dash-start (NonZero-coerced) public uint IFrameUntilTick;// StartTick + iFrameWindow (NonZero); i-frames active while this .IsNewerThan(SourceTick) public uint RecoverUntilTick;// IFrameUntilTick + recoverTail (NonZero); movement-lock tail, no i-frames } ``` NOT a `[GhostField]` (so no player-ghost re-bake). The SERVER re-derives it every predicted tick from the replicated `Dash` InputEvent, so it is authoritative at drain time. Bake DISABLED-equivalent (all-zero) via PlayerCharacterAuthoring AddComponent. Doc-comment must state: SHAPE-clone of KnockbackState, but UNLIKE KnockbackState it lives on a PREDICTED player and is re-simulated from input (not server-only-mutated on an interpolated ghost). ### NEW `DashCooldown` (Simulation/Player/DashCooldown.cs) — AbilityCooldown twin ``` public struct DashCooldown : IComponentData { [GhostField] public uint NextTick; } // 0 = ready ``` `[GhostField]` so the owning client doesn't mispredict cooldown across rollback/reconnect — exactly AbilityCooldown.cs:28. Bake `{NextTick=0}`. ### NEW `LungeState` (Simulation/Combat/LungeState.cs) — server-only, KnockbackState twin, baked ONLY on the Charger prefab ``` public struct LungeState : IComponentData { public float2 Dir; // fixed lunge heading, locked at commit public float Speed; // lunge speed (units/s); 0 = not lunging public uint UntilTick; // raw tick the lunge ends (NonZero); active while .IsNewerThan(serverTick) } ``` NOT a `[GhostField]` (lunged position replicates via stock LocalTransform, like KnockbackState). **Component-presence IS the Charger discriminator** — no enum/brain byte (honors the Burst cross-assembly-enum rule; EnemyAISystem is `[BurstCompile]`). ### EDIT `EnemyStats` (Simulation/Combat/EnemyComponents.cs) — add per-prefab windup so the Charger telegraph does NOT globally slow every Husk Add `public int WindupTicks;` Grunt bakes ~18 (current global), Charger bakes ~30. EnemyAISystem reads `stats.WindupTicks` instead of the global `Tuning.AttackWindupTicks` at the windup-set site (EnemyAISystem.cs:167). (The knob surface promotes `Tuning.AttackWindupTicks`→28 globally — REJECT that for the Charger; per-prefab is the right first cut. Keep a TuningConfig singleton override only if live-tuning the Charger lead is wanted; baked-per-prefab is the MC-1 default.) ### MC-0 prerequisite — `DevTelemetry` ALREADY EXISTS (Simulation/Debug/DevTelemetry.cs) Verified present as a server-only `IComponentData` singleton with the exact counters: `DashIFrameNegatedHits`, `DashesWasted`, `ChargerWhiffWindowsOpened`, `ChargerWhiffPunishesLanded`. NO blocker on telemetry home. (The MC-0 TuningConfig live-singleton for feel knobs is referenced as "building" — see Blockers: confirm it lands before wiring DashSystem to read LIVE values; until then use the baked defaults below.) --- ## 2. SourceTick stamp — all THREE sites, each with its OWN appending-tick clock, all via TickUtil.NonZero The negation compares `DamageEvent.SourceTick` against the player's stored DashState window. The stamp MUST be the tick the strike LANDS (the appending tick), NOT the drain tick — because EnemyAISystem/TurretFireSystem append in the PLAIN group (drained tick N+1), while ProjectileDamageSystem appends in the predicted group (drained same tick). 1. **EnemyAISystem.cs:144** (melee strike) — add `SourceTick = TickUtil.NonZero(now)` (`now` = the local var already computed at line 63 = `serverTick.TickIndexForValidTick`). 2. **ProjectileDamageSystem.cs:129** — add `SourceTick = TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick)` (`nt` is fetched at line 64, guarded by `haveTick`; if `!haveTick` stamp 0 — treated as no-i-frame). 3. **TurretFireSystem.cs:95** — add `SourceTick = TickUtil.NonZero(now)` (`now` = line 40 = `serverTick.TickIndexForValidTick`). 4. The **MC-4 cleave** and **EB-1/EB-2** future DamageEvent appends must also stamp SourceTick (already called out in the spec). NonZero at every site mirrors how StartTick/IFrameUntilTick are coerced, so a stamped event is never 0 and never collides with the "0 = ready" sentinel. --- ## 3. The i-frame negation in HealthApplyDamageSystem — HALF-OPEN, PER-ELEMENT Add in HealthApplyDamageSystem.OnUpdate, inside the per-entity foreach, AFTER the RespawnInvuln gate (lines 52-64) and at the sum loop (lines 66-69). It must be **per-element** (skip only the in-window DamageEvent), NOT a whole-buffer clear like RespawnInvuln/GodMode — a same-tick turret/projectile event on the player must still apply (defensive correctness; in practice only enemy melee targets the player today, but per-element is the documented-correct shape). ``` bool hasDash = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent(entity); DashState ds = hasDash ? SystemAPI.GetComponent(entity) : default; float total = 0f; for (int i = 0; i < dmg.Length; i++) { uint src = dmg[i].SourceTick; if (hasDash && src != 0 && ds.IFrameUntilTick != 0) { var srcTick = new NetworkTick(src); var startTick = new NetworkTick(ds.StartTick); var untilTick = new NetworkTick(ds.IFrameUntilTick); // i-framed iff StartTick <= src AND src < IFrameUntilTick (half-open, matches RespawnInvuln/cooldown convention) bool atOrAfterStart = srcTick.IsValid && startTick.IsValid && !startTick.IsNewerThan(srcTick); // src >= start bool beforeUntil = untilTick.IsValid && untilTick.IsNewerThan(srcTick); // src < until if (atOrAfterStart && beforeUntil) { telemetry.DashIFrameNegatedHits++; // MC-0 increment (per negated event) continue; // negate this event } } total += dmg[i].Amount; } dmg.Clear(); ``` **Convention pin (BLOCKER 1):** the window is `[StartTick, IFrameUntilTick)` — lower bound INCLUSIVE, upper bound EXCLUSIVE — matching RespawnInvuln/AbilityCooldown/EnemyAttackCooldown where `until.IsNewerThan(now)` is false at `now==until` (i.e. the gate OPENS at tick==until). Store `IFrameUntilTick = StartTick + iFrameWindowTicks`; tick==IFrameUntilTick is NO LONGER i-framed. `src==0` (unstamped) is treated as NEVER i-framed (damage applies — fail-safe). Use NetworkTick comparisons ONLY (never raw uint), so tick-wraparound is correct. `telemetry` = `SystemAPI.GetSingletonRW()` fetched once at top of OnUpdate (guard with TryGetSingleton; if absent, skip increments — keeps EditMode worlds without a telemetry singleton green). --- ## 4. Predicted DashSystem (Simulation/Player/DashSystem.cs) Attributes: `[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]` `[UpdateAfter(typeof(PlayerControlSystem))]` `[BurstCompile]` (plain ISystem; no managed). Filter `.WithAll().WithDisabled()`. Verify in Play it sorts BEFORE `PredictedFixedStepSimulationSystemGroup` (PlayerControlSystem already establishes that precedent — match it; the override must land before the processor lerps that tick). Query: `RefRW, RefRW, RefRW, RefRW, RefRO, RefRO` + `.WithEntityAccess()` (need InputBufferData if reading absolute Dash count — optional, see below). Per-tick logic (the START is an idempotent pure function of replicated input+tick → NO IsFirstTimeFullyPredictingTick guard; the OVERRIDE must run EVERY predicted pass so rollback re-applies it): ``` uint now = serverTick.TickIndexForValidTick; // serverTick = GetSingleton().ServerTick // --- one-off START (idempotent): fresh press edge + cooldown ready + not already mid-dash --- bool ready = cd.NextTick == 0 || !new NetworkTick(cd.NextTick).IsNewerThan(serverTick); bool windowActive = ds.RecoverUntilTick != 0 && new NetworkTick(ds.RecoverUntilTick).IsNewerThan(serverTick); if (input.Dash.IsSet && ready && !windowActive) { float2 dir = facing.Direction; // FREE AIM: dash heading = current facing (locked decision: free aim during dash) if (math.lengthsq(dir) < 1e-6f) dir = new float2(0,1); dir = math.normalize(dir); ds.Dir = dir; ds.StartTick = TickUtil.NonZero(now); ds.IFrameUntilTick = TickUtil.NonZero(now + iFrameWindowTicks); // default 12 ds.RecoverUntilTick= TickUtil.NonZero(now + iFrameWindowTicks + recoverTailTicks); // +9 cd.NextTick = TickUtil.NonZero(now + dashCooldownTicks); // default 45 } // --- per-pass OVERRIDE while i-frame window active (idempotent; runs on every rollback re-sim) --- bool iFrameActive = ds.IFrameUntilTick != 0 && new NetworkTick(ds.IFrameUntilTick).IsNewerThan(serverTick); if (iFrameActive) { control.MoveVelocity = new float3(ds.Dir.x, 0f, ds.Dir.y) * dashSpeed; // dashSpeed = dashDistance / iFrameWindowSeconds characterComponent.GroundedMovementSharpness = dashSharpness; // ~200 -> blink } else if (windowActive_recover) { // recovery tail: i-frames OFF, movement still locked-low so a panic-dash is punishable (no input control) control.MoveVelocity = float3.zero; characterComponent.GroundedMovementSharpness = 15f; // restore so the stop is crisp } else { // window fully elapsed: restore sharpness if we ever changed it characterComponent.GroundedMovementSharpness = 15f; } ``` - Does NOT write rotation — free-aim/strafe-dodge is automatic (PlayerAimSystem owns facing, not gated by dash). Confirmed PlayerAimSystem.cs:24-27. - `dashSpeed` derived from the distance knob so distance is the tuned value: `dashSpeed = dashDistance / (iFrameWindowTicks / 60f)`. - **Death/cleanup (major):** DashSystem is `.WithDisabled()` so it won't visit a dead player → a player who dies mid-dash leaves a stale future window + sharpness=200, which would grant spurious i-frames + a stuck-fast respawn. FIX: in PlayerDeathStateSystem (which already visits `.WithPresent()` and zeroes MoveVelocity, lines 30-39), ALSO when `isDead`: zero DashState (StartTick/IFrameUntilTick/RecoverUntilTick = 0) and restore `GroundedMovementSharpness = 15`. Add DashState + CharacterComponent to that query. --- ## 5. Charger LungeState branch in EnemyAISystem (server-only, sole position writer) This is the largest hidden cost — a REWRITE of the strike branch for the Charger, not an additive field. Discriminate by `LungeState` component presence via a SECOND query pass (`.WithAll()`) OR an optional `ComponentLookup` on the existing query — a second pass is cleaner and keeps the Grunt path byte-free. Recommend: keep the existing Grunt foreach unchanged; add a Charger-specific foreach that ALSO matches `LungeState`, and EXCLUDE Chargers from the Grunt pass via `.WithNone()` so a Charger is driven only by the Charger branch. **Explicit state precedence (in one place):** knockback > lunge-active > lunge-commit(windup-elapse) > seek/strike. Each `continue`s after writing Position (preserve the SOLE-writer invariant — NO separate LungeSystem). Charger per-enemy logic: 1. **Knockback wins** (a shot staggers the charge): reuse the existing knockback branch (lines 80-94); ADD `lunge.ValueRW.UntilTick = 0` there so a knocked-back Charger's lunge is cancelled (otherwise two position writers contend). Keep `windup=0`. 2. **Lunge active** (`LungeState.UntilTick` newer than serverTick): move fixed `lunge.Dir` at `lunge.Speed*dt` via `SweptMove`; hold Y. Deal contact damage DURING travel: if `EnemyAIMath.InAttackRange(pos, nearestPlayerPos, AttackRange)` append the `DamageEvent{Amount, SourceNetworkId=-1, SourceTick=NonZero(now)}` (at-most-once via cooldown). **Whiff detection:** compare intended displacement vs SweptMove-actual; if `actualTravel < intendedTravel * whiffFraction` (wall-stop) OR the lunge timer elapses without ever entering AttackRange (overshoot) → enter stagger: `cooldown.NextAttackTick = TickUtil.NonZero(now + whiffStaggerTicks)` (default 36) and clear `lunge.UntilTick`; `telemetry.ChargerWhiffWindowsOpened++`. `continue`. - To get the wall-hit signal cleanly: have `SweptMove` return the travel fraction (it already has `out var hit` internally), or recompute `|result-pos| / |intended-pos|` at the call site. 3. **Lunge commit** (windup-elapse, replaces the instant strike at lines 144-152 FOR THE CHARGER ONLY): do NOT append the instant DamageEvent. Instead capture `lunge.Dir = normalize(targetPos - pos)` ONCE, set `lunge.Speed = chargerLungeSpeed` (default 16), `lunge.UntilTick = TickUtil.NonZero(now + lungeDurationTicks)`, clear `windup`. The lunge travels next tick. 4. **CRITICAL — cancel-on-leave-range must NOT apply to the Charger commit.** EnemyAISystem.cs:135-137 cancels the windup if the target leaves AttackRange. For the Charger, the whole point is the commit FIRES even when the player has dodged out of range. So the Charger windup must NOT use the cancel path — once the long windup elapses, it locks dir and lunges regardless of current range. (Gate the cancel to the Grunt pass only; the Charger pass has no leave-range cancel.) 5. Charger uses `stats.WindupTicks` (~30) for the telegraph, set at the windup-arm site. The Charger's commit telegraphs via the EXISTING `[GhostField] AttackWindup.WindUpUntilTick` — CombatFeedbackSystem already edge-detects it (CombatFeedbackSystem.cs:127,132-137). The longer lead is the readable tell. **Whiff-punish telemetry:** in HealthApplyDamageSystem, when a player-sourced DamageEvent (`SourceNetworkId >= 0`) lands on an entity that has a Charger `LungeState` AND its `EnemyAttackCooldown.NextAttackTick` is in the whiff-stagger window → `telemetry.ChargerWhiffPunishesLanded++`. --- ## 6. Charger prefab variant (new ghost, additive — no spawn-code change) - NEW `ChargerAuthoring : MonoBehaviour` (duplicate EnemyAuthoring) that bakes the same Husk components PLUS `AddComponent(entity)` (zeroed) and bakes Charger EnemyStats (longer WindupTicks ~30, lunge-appropriate MoveSpeed). Keep the GhostAuthoring (interpolated, ownerless) inherited by duplicating an existing interpolated ghost prefab (Husk.prefab or UpgradePickup.prefab) per the new-ghost recipe. - Add the Charger prefab to the `WaveEnemyPrefab` buffer pool — WaveSystem.cs:83-92 spawns it round-robin via `baked.WithPosition` unchanged. **Churn class: new ghost prefab, additive — no re-hash of Grunt/Swarmer/Brute.** --- ## 7. Input wiring (.inputactions + wrapper regen) - VERIFIED: `Assets/Settings/Project M Input.inputactions` has only Move/Aim/Fire. Fire binds `/leftButton`, `/rightTrigger`, AND `/space` (lines 195-213). The wrapper `ProjectMInput.cs` is correctly routed into ProjectM.Client via `wrapperCodePath` in the .meta (gotcha already honored). - Add a `Dash` Button action. **Do NOT bind to Space** — Space is BOTH already a Fire binding (would double-fire) AND in the kbm-active sentinel (PlayerInputGatherSystem.cs:105). Bind Dash to `/leftShift` + `/buttonEast` (or another free pad button — not rightTrigger which is Fire). - Regenerate the wrapper via the importer (re-import the .inputactions on a FOCUSED editor — generated GUID-referenced code, do NOT hand-edit). wrapperCodePath stays `Assets/_Project/Scripts/Client/Input/ProjectMInput.cs`. - In PlayerInputGatherSystem (mirror Fire exactly, lines 160-162): add `bool dashPressed = gameplay.Dash.WasPressedThisFrame() && !BuildPaletteState.Active;` then in the per-player loop `input.ValueRW.Dash = default; if (dashPressed) input.ValueRW.Dash.Set();`. **Fold the keyboard dash key into `kbmActive`** (add `keyboard.leftShiftKey.isPressed` to line 100-106) and the gamepad dash button into `gamepadActive` (add `gamepad.buttonEast.isPressed` — already partially there at line 90) so a dash-only actuation flips the active scheme correctly. - **Churn: command-hash change** (new InputEvent on PlayerInput) — both peers rebuild; the wire type is unconditional (no `#if`) so the handshake matches; no prefab re-bake. --- ## 8. Dash juice (must be in MC-1) Client-only, in CombatFeedbackSystem (PresentationSystemGroup, observe-only). Edge-detect the local `DashCooldown.NextTick` advance exactly as it edge-detects `AbilityCooldown.NextFireTick` for the muzzle flash (CombatFeedbackSystem.cs:189-202): on the 0→nonzero / advance edge fire afterimage/whoosh + a directional camera nudge (camera punch, NEVER Time.timeScale) + an i-frame shimmer. The client can derive its own DashState identically. **Suppress player hit-feedback while the local i-frame window is active** (the client derives DashState too) so the prediction-reconciliation Health flicker (client predicts hit → server negates → snapshot corrects) does NOT read as a phantom "I got hit" flash on a clean dodge — this is the documented acceptable-not-a-bug interaction. ## 9. Telemetry increment sites (MC-0 DevTelemetry, already exists) - `DashIFrameNegatedHits++` — per negated event in HealthApplyDamageSystem (§3). - `DashesWasted++` — in DashSystem, lazily WHEN the dash window CLOSES having negated 0 hits (define "wasted" = the i-frame window overlapped no incoming Charger strike SourceTick), NOT at dash-start (so it counts genuine mistimes, not early presses). Simplest: track a per-player negated-count on DashState and on window-close, if 0 negations occurred this dash, increment. - `ChargerWhiffWindowsOpened++` — EnemyAISystem when a lunge enters stagger (§5.2). - `ChargerWhiffPunishesLanded++` — HealthApplyDamageSystem when a player-sourced hit lands on a staggered Charger (§5). ## Knob defaults (baked until the MC-0 TuningConfig live-singleton lands; then promote the feel-critical ones) Dash distance 4.0 · i-frame window 12 ticks · recovery tail 9 ticks · cooldown 45 ticks · sharpness ~200 (baked const) · Charger telegraph lead 30 ticks · Charger lunge speed 16 · whiff-stagger 36 ticks · Charger windup (per-prefab EnemyStats) ~30. Route every stored tick through TickUtil.NonZero; compare with NetworkTick.IsNewerThan only. ## EDITMODE TESTS 1. NEGATION BOUNDARY (half-open window) — seed NetworkTime.ServerTick = drainTick (e.g. T+1) via the TelegraphTests SetServerTick pattern; put DashState{StartTick=S, IFrameUntilTick=S+W} on the entity; append DamageEvents with SourceTick in {S-1, S, S+1, S+W-1, S+W, S+W+1}; tick HealthApplyDamageSystem once; assert Health UNCHANGED for src in [S, S+W) (i.e. S, S+1, S+W-1 negated) and REDUCED for src==S-1, src==S+W, src==S+W+1. Pins the exact first-negated (S) and first-NOT-negated (S+W) ticks so the boundary is frozen. 2. TICK-WRAPAROUND negation — DashState.StartTick near uint.MaxValue, IFrameUntilTick wrapping past 0; DamageEvent.SourceTick straddling the wrap; assert NetworkTick.IsNewerThan negates correctly (proves no raw-uint compare leaked in). 3. SourceTick==0 fail-safe — append a DamageEvent with SourceTick=0 while a DashState window is active; assert damage APPLIES (unstamped events are never i-framed). 4. PER-ELEMENT (not whole-buffer) negation — same tick, append one in-window melee DamageEvent (SourceNetworkId=-1) AND one out-of-window event; assert ONLY the in-window event is negated, the other still subtracts (proves the dash gate is per-element, unlike the whole-buffer RespawnInvuln/GodMode clears). 5. RespawnInvuln still whole-buffer — regression: a DashState entity ALSO under RespawnInvuln negates ALL damage (RespawnInvuln path unchanged, runs before the per-element dash loop). 6. DASH START IDEMPOTENCE (rollback determinism) — register DashSystem in a bare predicted-style group; set Dash.IsSet + DashCooldown ready; run the start tick TWICE at the same ServerTick; assert identical StartTick/IFrameUntilTick/RecoverUntilTick/DashCooldown.NextTick (the start is a pure function of input+tick; no double-trigger). 7. DASH COOLDOWN GATE — dash, advance ServerTick past IFrameUntilTick but before DashCooldown.NextTick, set Dash.IsSet again; assert NO new dash (window unchanged); advance past cooldown, set again; assert a new dash starts. 8. DEATH MID-DASH CLEANUP — dash (window in the future), set Health<=0, tick PlayerDeathStateSystem; assert DashState window zeroed AND GroundedMovementSharpness restored to 15 (no spurious respawn invuln, no stuck-fast). 9. DASH VELOCITY OVERRIDE ORDERING — with DashState i-frame active, tick PlayerControlSystem then DashSystem; assert CharacterControl.MoveVelocity == Dir*dashSpeed (DashSystem overrode PlayerControl's input velocity) and GroundedMovementSharpness == dashSharpness. 10. STAMP NON-ZERO — drive each of the three append sites (or unit-test the stamp expression) and assert the produced SourceTick is never 0 (NonZero coercion at all three). 11. CHARGER COMMIT FIRES OUT OF RANGE — Charger windup elapses while the target has LEFT AttackRange; assert it does NOT cancel (LungeState entered, Dir locked to target-at-commit) — contrasts with the Grunt cancel-on-leave-range path. 12. CHARGER WHIFF -> STAGGER — give a Charger a fixed lunge Dir and a target offset so SweptMove stops short / never enters range; tick the lunge to expiry; assert EnemyAttackCooldown.NextAttackTick is extended by whiffStaggerTicks, LungeState.UntilTick cleared, and ChargerWhiffWindowsOpened incremented. 13. CHARGER KNOCKBACK CANCELS LUNGE — a Charger mid-lunge receives KnockbackState; tick EnemyAISystem; assert the knockback branch wins (position written by knockback) AND LungeState.UntilTick is cleared (no two-writer contention). 14. CROSS-GROUP i-FRAME TICK-COVERAGE REGRESSION (the required tunnelling-style test, EXPRESSED AS A TICK-COVERAGE TABLE, with an explicit PLAY-validation note) — the plain EditMode harness CANNOT reproduce the predicted-vs-plain group split (systems register unsorted, one tick, no group separation), so this test does NOT tick EnemyAISystem then HealthApplyDamageSystem to recreate N->N+1. Instead it asserts the NEGATION COVERS the cross-group offset directly: seed drainTick = T+1, DashState window [T-2, T+3], append DamageEvents with SourceTick = {T-3, T-2, T (a melee strike authored at T, drained at T+1), T+2, T+3, T+4}; assert exactly the in-window ones (T-2..T+2) negate and T-3/T+3/T+4 apply — proving a strike authored at T (and drained a tick later) is still negated by the window that covered T. Include an assertion-comment that the actual N->N+1 group timing (EnemyAISystem appends at T, drains at T+1) is a PLAY-VALIDATION item (review agenda item #1: server's DashState window at drain time covers the melee strike appended a tick earlier in the plain group), NOT EditMode-reproducible. ## BUILD ORDER 1. 1. (CLAUDE) DamageEvent.SourceTick field + stamp all THREE sites (EnemyAISystem:144, ProjectileDamageSystem:129, TurretFireSystem:95) via TickUtil.NonZero. Headless, no behavior change yet. Edit Assets .cs via MCP apply_text_edits/create_script, read_console after. 2. 2. (CLAUDE) DashState + DashCooldown components; the HealthApplyDamageSystem per-element half-open negation branch + DevTelemetry.DashIFrameNegatedHits increment (DevTelemetry already exists). Write the negation-boundary + wraparound + per-element + SourceTick==0 EditMode tests FIRST/alongside (fully headless-coverable via the TelegraphTests SetServerTick pattern). This is the kill-switch foundation — Play-validate the cross-group alignment (agenda item #1) before building feel on top. 3. 3. (CLAUDE) Dash InputEvent on PlayerInput + ToFixedString; PlayerInputGatherSystem reset+Set + fold into kbmActive/gamepadActive. Edit the .inputactions JSON (Write is fine — non-asset). THEN: (OPERATOR-FOCUSED-EDITOR) re-import the .inputactions to regen ProjectMInput.cs on a FOCUSED editor (generated GUID code); command-hash change means BOTH peers rebuild (no MPPM half-update). 4. 4. (CLAUDE) DashSystem [UpdateAfter(PlayerControlSystem)] with the sharpness-override blink (DEFAULT path — NO CharacterProcessor edit, headless). PlayerDeathStateSystem cleanup (clear DashState + restore sharpness on death). Dash-determinism + cooldown + death-cleanup + override-ordering EditMode tests. (OPERATOR-FOCUSED-EDITOR) Play-validate the SNAP TEST (RelativeVelocity reaches dash speed in 1-2 ticks) + server==client DashState window via execute_code; confirm DashSystem sorts before PredictedFixedStepSimulationSystemGroup. Verify CharacterControlUtilities.StandardGroundMove_Interpolated lerp form via unity_reflect before committing sharpness ~200. 5. 5. (OPERATOR-FOCUSED-EDITOR, FALLBACK ONLY) IF Play shows the lerp-to-200 still visibly ramps: the direct characterBody.RelativeVelocity write inside CharacterProcessor.HandleVelocityControl — THIS is the Burst-affecting edit (Burst-off for the session, expect a restart, Play-validate). Skip entirely if the sharpness path passes the SNAP TEST (expected). 6. 6. (CLAUDE) LungeState component (server-only); EnemyStats.WindupTicks per-prefab field; the Charger branch rewrite in EnemyAISystem (knockback>lunge>commit>seek precedence, SweptMove whiff detection, no cancel-on-leave-range for the Charger) + ChargerWhiffWindowsOpened/PunishesLanded increments. Charger commit/whiff/knockback-cancel EditMode tests. read_console for the Burst cross-assembly-enum/generic hazards (component-presence discriminator keeps it byte-free). 7. 7. (OPERATOR-FOCUSED-EDITOR for prefab authoring) ChargerAuthoring (duplicate EnemyAuthoring + AddComponent); duplicate an interpolated ghost prefab for the GhostAuthoring; add the Charger to the WaveEnemyPrefab pool via manage_scene/manage_prefabs; verify baked components via execute_code. Additive ghost — no Grunt re-hash. 8. 8. (CLAUDE) Dash juice in CombatFeedbackSystem (edge-detect DashCooldown; afterimage/whoosh/camera-nudge/shimmer; suppress player hit-feedback during the local i-frame window). DashesWasted increment (lazy, on window-close with 0 negations). 9. 9. (OPERATOR) MANDATORY netcode design review BEFORE create_script of the netcode-heavy slices (agenda item #1 = cross-group i-frame alignment), then the bench (timed vs spam, >=70% fewer hits) + the friend-read at Demo A. ALL feel tuning. This is the kill-switch gate. ## BLOCKERS - MC-0 TuningConfig LIVE-SINGLETON for feel knobs is NOT confirmed in code. DevTelemetry (the counters) EXISTS at Simulation/Debug/DevTelemetry.cs — good — but I found no TuningConfig/feel-knob singleton (Tuning.cs is still compile-time consts only). The dash/Charger LIVE-tuning loop the fun-gate depends on requires that singleton. NOT a hard blocker for building MC-1 with BAKED defaults, but the operator must confirm MC-0's SetTuning singleton lands BEFORE the tuning pass, or DashSystem/EnemyAISystem must read baked consts first and be re-pointed at the singleton later. Either decide to land the TuningConfig singleton in MC-0 now, or accept baked-const defaults for the first Play pass. - The mandatory netcode design review (agenda item #1: at the drain tick, does the server's DashState i-frame window compared via SourceTick correctly cover a melee strike appended a tick earlier in the plain group) MUST run BEFORE create_script of the negation + DashSystem + Charger slices, per CLAUDE.md and the operator's standing rule. This is a process gate, not a code defect — flagged so it is not skipped under time pressure. ## OPEN CONCERNS FOR OPERATOR - DASH-FEEL FORK RESOLVED IN CODE: the 'CharacterProcessor edit is the only focused-editor piece' framing in the spec is FALSE for the DEFAULT path. CharacterProcessor.cs:117 reads GroundedMovementSharpness as a RW ref, so a predicted DashSystem raising it to ~200 produces the blink with NO processor edit and NO Burst restart (R3 drops out of MC-1). The Tuning-knob surface itself names sharpness ~200 as the fix. Recommend trying the sharpness path FIRST; keep the direct RelativeVelocity processor write as a documented fallback that carries the Burst-restart ONLY if Play shows a visible ramp. This likely makes MC-1's movement slice fully headless except the .inputactions wrapper regen. - PER-PREFAB WINDUP vs GLOBAL PROMOTION: the knob surface proposes promoting Tuning.AttackWindupTicks to ONE singleton at 28, which would slow EVERY Husk's strike (changing the existing fight feel), not just the Charger. Recommend a per-prefab EnemyStats.WindupTicks (Grunt ~18, Charger ~30) instead; promote to a live singleton later if Charger-lead live-tuning is wanted. Confirm this is acceptable (it diverges from the literal knob-surface row). - RE-DASH AT T+1 EDGE: if a player dashes again on the exact tick a prior strike is being drained, the new dash overwrites the window before the drain reads it, so the prior strike's coverage could change. v1 accepts this 1-tick edge (the player chose to re-dash) and the negation reads SourceTick against whatever window is current. If this proves exploitable/confusing in playtest, add a 1-slot window history (PrevStartTick/PrevUntilTick). Flagging so it is an explicit accepted edge, not a silent bug. - PREDICTION-RECONCILIATION FLICKER is EXPECTED in the very playtest that gates the track (client predicts the hit, server's server-only i-frame negates, next snapshot corrects Health). It is acceptable/server-authoritative, masked by the local i-frame shimmer + hit-feedback suppression. Document this in the fun-gate protocol so a tester does not read it as 'flaky i-frames' and fail the gate spuriously. - CHARGER STRIKE-BRANCH REWRITE is the largest hidden cost — today EnemyAISystem deals contact damage INSTANTLY on windup-elapse; the Charger needs windup-elapse to instead ENTER a fixed-dir lunge that travels and damages only on contact-during-travel, with whiff detection from SweptMove displacement and NO cancel-on-leave-range. Budget this as a real branch + a SweptMove signature change (return the travel fraction / wall-hit bool), not an additive field. The ground-ring/scale-up telegraph RAMP is net-new CombatFeedbackSystem presentation work (the edge-detect scaffold is reused; the ramped visual is new) — slightly understated as 'mostly tuning' in the spec.