Vault Re-Alignment
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
---
|
||||
date: 2026-06-08
|
||||
type: session
|
||||
tags:
|
||||
- session
|
||||
- design
|
||||
- direction
|
||||
- combat
|
||||
- roadmap
|
||||
- scope
|
||||
permalink: gamevault/07-sessions/2026/2026-06-08-combat-depth-direction
|
||||
---
|
||||
|
||||
# Session 2026-06-08 — Direction: combat-first + the combat-depth track
|
||||
|
||||
> A design/strategy session (no code). Operator: *"This does not feel like a game... expand the design + a clear roadmap to a core playable fun loop. Pushback where it'd substantially help. Ground it in real game design + real published games + the solo-dev constraint."*
|
||||
|
||||
## Goal
|
||||
|
||||
Diagnose why an engineering-complete project doesn't feel like a game, set a direction, and lay out a combat-depth milestone track.
|
||||
|
||||
## Process
|
||||
|
||||
- **Scan:** read [[Identity]], [[Pillars]], [[Milestones]], [[Backlog]], [[Systems_Index]], DR-004, content inventory (3 abilities = same projectile; 6 enemy prefabs = 1 brain; 8 items; structures). Confirmed the gap: vast infrastructure, hollow content; "Live interactive fire test" never done.
|
||||
- **Intake gate (4 questions):** operator wants **all three pillars** (the fusion is the identity / unsure), **passion/craft**, **co-op NON-NEGOTIABLE**, **solo + Claude**.
|
||||
- **Diagnosis + pushback** (delivered in chat): development was breadth-first + correctness-first → a "tech demo of a game"; the four pillars are three deep genres a solo dev can't make co-equal; the fix is braid-don't-co-equal with combat as the primary verb. Grounded in The Riftbreaker / Core Keeper / Deep Rock Galactic / Risk of Rain 2 / Left 4 Dead / Vlambeer.
|
||||
- **Combat-depth design pass (ultracode workflow, 9 agents):** 5 design lenses (movement/defense · ability kit · enemy AI · co-op · game feel), each grounded in real games **and** the actual DOTS code → 1 synthesis (thesis + MC-1…MC-5) → 3 adversarial critics (netcode-feasibility · solo-scope · fun), all **go-with-changes**.
|
||||
|
||||
## Done (decisions + docs)
|
||||
|
||||
- **[[DR-028_Combat_Primary_Verb_Depth_First]]** — combat is the primary braided verb; base+automation braid around it (not co-equal); **depth-before-breadth** + per-milestone **fun-gates**; inventory Phases 2–4 + automation breadth paused.
|
||||
- **[[Path_to_Fun]]** — new north-star roadmap: the braided loop + the combat-depth track **MC-1…MC-6** (re-cut per the critics) with build notes.
|
||||
- Revised [[Pillars]] (combat primary + depth-before-breadth), [[Identity]] (+the braid section), [[Milestones]] + [[Backlog]] (pivot/pause), [[00_Home/Home|Home]] (pointers).
|
||||
|
||||
## Key findings from the design pass (carry into MC-1)
|
||||
|
||||
- **The keystone is enemy COMMITMENT + a punishable whiff paired with the dodge.** The dash is the *answer*, a committed lunge is the *question*; both are inert alone (aim is already decoupled from move → kite-strafe-and-click already beats the one brain). So MC-1 ships the dash **and** the Charger lunge as one playtest unit, validated by play, not against the current commitment-free melee.
|
||||
- **Two MC-1 netcode blockers (pre-caught):** (1) i-frames must negate damage per-`DamageEvent` against the tick it was authored — stamp a non-replicated `uint SourceTick` on `DamageEvent` (`HealthApplyDamageSystem` drains the prior-tick melee event in the predicted group), not "is DashState active now." (2) `CharacterControl` has no sharpness field — also drive `CharacterComponent.GroundedMovementSharpness` near-instant for the dash window, else the dash ramps ("walk faster"). Budget the Burst-processor edit (focused editor; expect a restart).
|
||||
- **Telegraph readability is a PRECONDITION, not polish:** drive the cue off the **replicated `AttackWindup` tick countdown** (enemies are interpolated ~100ms late); lengthen windups to ~27+ ticks; make enemy-projectile dodgeability a Play-gate.
|
||||
- **Co-op validated early + cheap:** pull a server-only `EnemyStatus` synergy byte into MC-3 (slow+burst duo > two soloists). Downed/revive (MC-5) replicates a `[GhostField] uint DownedUntilTick` discriminator to keep the derive rollback-correct; no server-only `KnockbackState` on predicted players.
|
||||
- **MC-6 (multi-slot kit) is last + review-gated** — the only HIGH-risk item; ship hitscan/cone (no predicted spawn) before generalizing `ProjectileClassificationSystem` to a ghost-type set; 2–3 slots before 4.
|
||||
|
||||
## Addendum — roadmap refinement (same day, ultracode)
|
||||
|
||||
Operator: *"Lets refine the roadmap fully."* A second multi-agent pass refined [[Path_to_Fun]] end-to-end — 4 deepen lenses (combat track · the economy braid · endgame/win-lose · production discipline, each grounded in the actual code) → synthesis → 3 code-grounded adversarial critics (netcode-feasibility · solo-scope · fun-coherence), all **go-with-changes**. Outcome:
|
||||
|
||||
- **Path A / Path B hard split + a mandatory Decision Gate.** Committed **Path A** = MC-0 (instrument the box) · MC-1 (dash + committed Charger) · MC-4 (melee cone) · EB-1 (machines can die) · EB-2 (felt spend / turret ammo from the factory) · END-1 (a losable Core) · END-2 (final siege, win/lose) — *a complete, shippable small game with a point.* Everything else (MC-2/3/5/6 · EB-3/4/5 · END-5) is **provisional Path B**, re-estimated only after Path A's fun-gates pass; **END-3 narrative + END-4 content-treadmill CUT** to the revisit table (an empty event bus is negative value for a content-light solo project). No Path B work begins until an explicit ship-vs-continue decision is logged.
|
||||
- **Code-grounded corrections (critics read the systems):** a THIRD `DamageEvent` stamp site (`TurretFireSystem.cs:95`); `DashState` needs an explicit `StartTick`; the i-frame negation is a cross-group tick-alignment problem (`HealthApplyDamageSystem` in the predicted group drains the strike `EnemyAISystem` appended a tick earlier in the plain group) — MC-1 mandatory-review agenda item #1. The MC-2 "wave director" is already built (Threat/Cycle/Wave) — only the weighted enemy-MIX table is new.
|
||||
- **New cross-cutting discipline:** a falsifiable fun-gate protocol + MC-0 instrumentation (so feel claims are counted — e.g. timed-vs-spam dash hit-counts), a ~35-row tuning-knob surface (baked vs live server-singleton + defaults), the solo+Claude two-lane cadence (friend = an EXTERNAL Path-A dependency at EB-2's Demo B), a risk register (R1–R11), and demo checkpoints (Duel / Loop / Crisis). 10 operator forks catalogued (locked the next day — see Addendum 2).
|
||||
|
||||
Direction unchanged ([[DR-028_Combat_Primary_Verb_Depth_First]]); the refinement sharpened scope realism, falsifiability, and netcode precision.
|
||||
|
||||
## Addendum 2 — Path A forks locked (2026-06-09)
|
||||
|
||||
Operator process correction: *gameplay-design forks are the operator's call — present each with a recommendation, never auto-decide or mark a default "official" without an okay* (a workflow attempting to auto-lock every fork was halted mid-run). Worked the forks interactively; **Path A is now fully locked** ([[DR-029_Path_A_Fork_Locks]] · [[Path_to_Fun#Locked decisions (Path A)]]): free-aim + whole-window dash · Charger = a new prefab · cleave = its own button + cooldown · Husks push-for-base · soft-loss + wounded-base-persists (SaveData v3) · turret-only ammo with soft run-dry · win-meter = **both** sieges + Aether deposits (the one non-default pick — the stronger braid) · winning = keep-playing · final = one big escalating wave. Live-singleton picks stay tunable at playtest. **Standing rule:** re-run the same present-the-forks ritual before each Path B milestone; Path B forks stay open. Saw also [[present-forks-dont-auto-decide]].
|
||||
|
||||
## Next
|
||||
|
||||
Path A is locked and concretely specified. Operator's call: **start MC-0 + MC-1** (instrument the box, then dash + Charger committed-lunge + readable telegraph — honoring the three-site `SourceTick` i-frame fix, the `DashState.StartTick` window, and the Burst-affecting `CharacterProcessor` edit) via a normal plan→approve→build slice — or commit the refined vault docs. Per [[Path_to_Fun]], **MC-1 is the project kill-switch**: if its fun-gate fails after a real tuning pass, STOP and re-cut combat before building on it.
|
||||
@@ -0,0 +1,249 @@
|
||||
---
|
||||
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<PlayerInput> 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<DashState>(entity);
|
||||
DashState ds = hasDash ? SystemAPI.GetComponent<DashState>(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<DevTelemetry>()` 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<Simulate>().WithDisabled<Dead>()`. 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<DashState>, RefRW<DashCooldown>, RefRW<CharacterControl>, RefRW<CharacterComponent>, RefRO<PlayerInput>, RefRO<PlayerFacing>` + `.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<NetworkTime>().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<Dead>()` 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<Dead>()` 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<EnemyTag, LungeState>()`) OR an optional `ComponentLookup<LungeState>` 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<LungeState>()` 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<LungeState>(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 `<Mouse>/leftButton`, `<Gamepad>/rightTrigger`, AND `<Keyboard>/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 `<Keyboard>/leftShift` + `<Gamepad>/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<LungeState>); 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.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
date: 2026-06-09
|
||||
type: session
|
||||
tags:
|
||||
- session
|
||||
- combat
|
||||
- mc-0
|
||||
- mc-1
|
||||
- netcode
|
||||
- dash
|
||||
- charger
|
||||
permalink: gamevault/07-sessions/2026/2026-06-09-mc1-implementation
|
||||
---
|
||||
|
||||
# MC-0 + MC-1 implementation — dash, Charger, telemetry (code-complete; fun-gate pending)
|
||||
|
||||
> Implements [[2026-06-09_MC1_Build_Spec]] (the mandatory pre-code adversarial review's output). Direction: [[Path_to_Fun]] · locks: [[DR-029_Path_A_Fork_Locks]]. **Status: all code + tests + structural Play-validation done; the MC-1 FUN-GATE (feel pass + bench + friend read) is the open operator item — MC-1 is NOT "done" until it passes.**
|
||||
|
||||
## What was built (the full uncommitted slice)
|
||||
|
||||
### MC-0 — instrument the box
|
||||
- `DevTelemetry` (Simulation/Debug) — server-only singleton with the four fun-gate counters + LiveEnemyCount/LastSampleTick proof-of-life. `DebugTelemetryReport : IRpcCommand` — **unconditional wire type** (RpcCollection hash parity); only the systems are `#if UNITY_EDITOR`.
|
||||
- `DevTelemetrySystem` (Server) — ensures the singleton, samples each tick, ships the report to every connection every 15 ticks. `DevTelemetryReceiveSystem` (Client) — drains the RPC into the `DevTelemetryReadout` static; `DebugOverlay` renders the live counters.
|
||||
- **All four counters now wired**: `DashIFrameNegatedHits` (HealthApplyDamageSystem, per negated event) · `DashesWasted` (DashSystem window-close edge, via a new `DashState.NegatedCount` server-written field; close-edge is **server-gated on the singleton** so the client's DashState is never zeroed mid-rollback) · `ChargerWhiffWindowsOpened` (EnemyAISystem, both whiff sites) · `ChargerWhiffPunishesLanded` (HealthApplyDamageSystem: player-sourced hit inside a new `LungeState.StaggerUntilTick` window, **scored once per window** by zeroing on first punish so punishes:windows ≤ 1).
|
||||
|
||||
### MC-1 — dash
|
||||
- `DamageEvent.SourceTick` (non-replicated) stamped via `TickUtil.NonZero` at all THREE append sites (EnemyAISystem melee, ProjectileDamageSystem, TurretFireSystem); `SourceTick==0` = unstamped = never i-framed (fail-safe).
|
||||
- `DashState` (predicted, non-replicated, re-simulated from input) + `DashCooldown{[GhostField] NextTick}` — baked on the player via PlayerAuthoring. `PlayerInput.Dash` InputEvent (`Fire` twin, command-hash churn only — no ghost re-bake).
|
||||
- `DashSystem` (predicted, `[UpdateAfter(PlayerControlSystem)]`, Bursted): idempotent start (press + cooldown-ready + not-in-window), HALF-OPEN i-frame window `[StartTick, IFrameUntilTick)`, recovery tail (movement locked, no i-frames), **sharpness-override blink** (`GroundedMovementSharpness` 15→200 for the window — NO CharacterProcessor edit, fully headless, exactly as the spec's confirmed correction predicted).
|
||||
- Negation in `HealthApplyDamageSystem`: per-element (not whole-buffer), half-open, `NetworkTick` comparisons only (wrap-safe). `PlayerDeathStateSystem` clears the window + restores sharpness on death.
|
||||
- **Dash juice** in `CombatFeedbackSystem`: dash whoosh SFX + afterimage burst + camera shake + FOV punch on the `DashCooldown.NextTick` edge (muzzle-flash pattern); i-frame shimmer trail each frame the local window is active; **local hit-feedback suppressed during the local i-frame window** (masks the documented prediction-reconciliation Health flicker). All knobs in `FeelConfig` (Feature 5 block, live-pokeable).
|
||||
|
||||
### MC-1 — Charger
|
||||
- `LungeState` (server-only; **component-presence is the discriminator** — no enum in the Bursted system) + `ChargerAuthoring` (composes WITH EnemyAuthoring on the prefab; bakes only LungeState).
|
||||
- `EnemyAISystem` Charger pass (Grunt pass excludes via `.WithNone<LungeState>()`): precedence **knockback (cancels lunge) > lunge-active (SweptMove travel, contact damage, wall-stop + overshoot whiff → stagger) > seek > commit** — commit locks Dir at windup-elapse and **fires even if the target left range** (the punishable tell). Charger windup 30 ticks / lunge 16 u/s / 18 ticks / stagger 36 ticks (per-pass consts).
|
||||
- **Prefab chain**: `EnemyCharger.prefab` (capsule template: Enemy.prefab duplicate + ChargerAuthoring + tuned stats HP 45 / spd 2.6 / dmg 14 / cd 48 / scale 1.0) → `EnemyChargerMuscle.prefab` via a new **`EnemyRigTools` "Build Charger (MC-1)"** menu item (builds ONLY the charger so the committed Werewolf/Kaiju outputs aren't re-serialized). Model: **SM_Chr_Muscle_Male_01** (PolygonSciFiCity — the [[Synty_Asset_Inventory]] "verified Generic rig, next-faction" path), atlas `PolygonScifi_01_A` + red tint (danger read), RootY −1.0. Added as the 4th `WaveDirector.EnemyPrefabs` round-robin entry in the Gameplay subscene (additive ghost — no re-hash of existing ghosts).
|
||||
|
||||
## Validation (gates 1 + 2 of three)
|
||||
- **EditMode: 259/259 green** (spec tests incl. half-open boundary, wraparound, per-element, SourceTick-0 fail-safe, idempotent start, cooldown gate, death-mid-dash cleanup, override ordering, Charger commit-out-of-range / whiff-stagger / knockback-cancel, 7 telemetry-counter tests + the post-review rollback-window regression).
|
||||
- **Play (real netcode session, server+client): zero console errors** — no `ComponentSystemSorter` cycle, no stale-Burst exception. DashState/DashCooldown baked in BOTH worlds; DashSystem sorts directly after PlayerControlSystem (the documented 1-tick fixed-step offset pattern). **Live E2E negation**: armed a window on the server player, appended in-window + out-of-window strikes → only the outside one applied (100→93), counter +1, close-edge cleanup ran. **Live Charger**: spawned from the baked pool → seek → 30-tick telegraph → lunge commit (speed 16) → contact damage → repeat; replicates to the client; killed the (stationary) player → death/respawn flow exercised. **Telemetry pipe**: server counters → RPC → client `DevTelemetryReadout` → overlay, values matching. Charger material values verified (AnimatedLitShader + SciFi atlas + red tint).
|
||||
- Six "wasted" dashes were counted live with `serverCd == clientCd` — real input-driven dash starts replicated consistently (and wasted-counting works).
|
||||
|
||||
## Post-build adversarial review (4 lenses → 12 findings → 2 confirmed, both FIXED)
|
||||
|
||||
A 29-agent review workflow (netcode/prediction · DOTS/Burst · spec-adherence · edge-cases, each finding adversarially refuted) ran over the full diff. Ten findings were refuted as true-but-mitigated (by design, world placement, an existing test, or the documented deviations). Two were confirmed and fixed in-session:
|
||||
|
||||
1. **MAJOR — the dash override lacked the StartTick lower bound.** `DashState` is non-replicated → NOT restored on prediction rollback; after a press at tick D the client re-simulates pre-dash ticks S..D−1 with the post-press window visible, and an upper-bound-only `iFrameActive` stomped dash velocity + sharpness onto ticks that never had them → **dash-start overshoot + snap-back under real latency** (editor RTT≈0 masked it; the negation had the lower bound, only the movement override didn't — the bug originated in the spec's §4 pseudocode). **Fix:** `inDashWindow = StartTick != 0 && !StartTick.IsNewerThan(serverTick)` now gates both override branches; pre-dash re-sim ticks fall to the restore branch. Pinned by `Rollback_ReSimulated_PreDash_Tick_Gets_No_Override`.
|
||||
2. **MINOR — DashSystem vs HealthApplyDamageSystem were unordered** in the server's predicted group, so a same-tick teammate-projectile vs dash-start negation (`src == StartTick`; ProjectileDamageSystem appends and the drain runs the SAME tick) was an unconstrained sorter tiebreak. **Fix:** `[UpdateAfter(typeof(DashSystem))]` on HealthApplyDamageSystem (chains verified disjoint — no cycle; Play-validated: dash 13 → drain 14, clean world creation).
|
||||
|
||||
Notable refuted-but-recorded items: the punish check compares the stagger window against the *drain* tick (correct today — the only `SourceNetworkId>=0` site drains same-tick; revisit if MC-4's cleave appends in the plain group) · the dash displacement consumes velocity one tick after the i-frame window (the documented OrderFirst 1-tick offset; the shimmer matches the true negation window) · the build-server never clears DashState at window-close (intentional — all readers are tick-guarded; pinned by `Without_Telemetry_Singleton_The_Close_Edge_Leaves_DashState_Intact`) · Charger lunge contact only tests the current nearest player (the spec'd + pre-existing Grunt targeting model; a co-op design note for later).
|
||||
|
||||
(Process note: the first review run returned `{confirmed: []}` because all four agents died on a subagent session limit — an empty result that masquerades as a clean pass. Check the failures list; re-run after the reset via `resumeFromRunId`.)
|
||||
|
||||
## Deviations from the build spec (all deliberate)
|
||||
1. **Input = direct device reads** (`leftShift` / `buttonEast` `wasPressedThisFrame` in PlayerInputGatherSystem), NOT a `.inputactions` Dash action — kills the only focused-editor step (wrapper regen). Migrate to the action map later if rebinding UI ever needs it.
|
||||
2. **Per-pass consts for the Charger windup** (30 ticks in the Charger foreach) instead of an `EnemyStats.WindupTicks` field — same effect (Grunt feel untouched), one field less; promote to per-prefab/live-singleton when tuning demands it.
|
||||
3. **`LungeState.StaggerUntilTick`** added for punish scoring (the spec inferred the window from `EnemyAttackCooldown`, which would conflate post-hit cooldown with stagger and let one window score many punishes).
|
||||
|
||||
## Open items (operator)
|
||||
- **The MC-1 fun-gate** (gate 3): focused-editor feel pass — dash SNAP test (sharpness 200 must read as a blink, else the documented CharacterProcessor fallback + Burst restart), Charger telegraph readability, RootY feet check on the Muscle rig, then the bench (timed vs spam ≥70% fewer hits) + a friend read at Demo A. Live whiff/punish numbers only emerge with a dodging player.
|
||||
- **TuningConfig live-singleton** (the spec's MC-0 blocker) still does not exist — dash/Charger knobs are baked consts (+ FeelConfig statics for presentation). Decide whether to land it before the tuning pass.
|
||||
- `Assets/_Recovery/0.unity` (2.2 MB, 2026-06-08 23:36) — an untracked Unity scene-recovery artifact; review + delete (with its .meta) if it holds nothing.
|
||||
- Wave-spawned Chargers appear only when a siege runs (player-driven pacing) — the round-robin makes 1 in 4 spawns a Charger.
|
||||
|
||||
## Links
|
||||
[[2026-06-09_MC1_Build_Spec]] · [[Path_to_Fun]] · [[DR-028_Combat_Primary_Verb_Depth_First]] · [[DR-029_Path_A_Fork_Locks]] · [[DR-023_Enemy_Animation_MonsterMash]] (rig pipeline) · [[Synty_Asset_Inventory]]
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
id: DR-028
|
||||
title: Combat is the primary braided verb — depth-before-breadth, and the combat-depth track
|
||||
status: accepted
|
||||
date: 2026-06-08
|
||||
tags:
|
||||
- decision
|
||||
- design
|
||||
- roadmap
|
||||
- combat
|
||||
- scope
|
||||
permalink: gamevault/07-sessions/decisions/dr-028-combat-primary-verb-depth-first
|
||||
---
|
||||
|
||||
# DR-028 — Combat is the Primary Verb (braid, don't co-equal) + Depth-Before-Breadth
|
||||
|
||||
## Context
|
||||
|
||||
After M0–M7 + inventory/equipment (Phases 0–1), the project is engineering-rich but "doesn't feel like a game" (operator, 2026-06-08). Diagnosis this session: development has been **breadth-first and correctness-first** — every milestone proved a *system replicates deterministically* (server==client, EditMode green); almost none proved a *loop is fun*. The tell: **"Live interactive fire test" sat OPEN in the [[Backlog]] after a dozen milestones** — combat, pillar #1, was never once playtested for enjoyment. Result: enormous, correct infrastructure over hollow content. Combat = ONE verb (a projectile with stat variants, a single `AbilityRef.Id`); SIX enemy prefabs share ONE brain (`EnemyAISystem` seek-nearest + contact melee); NO dodge/dash; death = free respawn. "Skill expression over stat-checks" is unmet because there is no skill surface — and aim is already decoupled from move, so kite-strafe-and-click trivially beats the only brain.
|
||||
|
||||
The four [[Pillars]] read as **four co-equal genres** — action combat + co-op base + automation + netcode. That is three deep genres in one game, which a solo dev (even Claude-accelerated) cannot make co-equally deep, and building them all breadth-first is *why* there is no fun. Operator intake (2026-06-08): wants ALL THREE (the fusion is the identity), **co-op NON-NEGOTIABLE**, **passion/craft** (no deadline), **solo + Claude** (content-light). Grounding in real small-studio games that fuse combat+base+automation — **The Riftbreaker** (combat-led), **Core Keeper** (mining-led, literal conveyors+drills, co-op) — confirms the fusion is achievable but ALWAYS picks one primary verb and braids the others around it; none makes all three co-equal.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Combat is the PRIMARY VERB.** Base-building and automation are not cut — they **braid around combat** as its stakes and economy: automation makes what you fight *with* (charges/munitions/turrets/upgrades); sieges threaten what automation lives *in* (the base/machines, with a real loss beat); the sortie feeds both. See [[Path_to_Fun]]. "All three" stays the identity, but as ONE braided loop with a single primary verb — never three co-equal, independently-deep modes (which is the current state and the thing that doesn't feel like a game).
|
||||
2. **Why combat (not base/automation) is the verb:** co-op-non-negotiable points at *shared combat* (the most reliable co-op fun — Deep Rock Galactic / Risk of Rain 2 / Vermintide); combat is structurally furthest along; combat is the most hollow, so it has the highest fun-per-hour return.
|
||||
3. **Depth-before-breadth is the operating rule.** No new SYSTEM until one braided loop is genuinely fun. The validation culture shifts: green EditMode + server==client are **necessary, not sufficient** — every milestone now ends with a **fun-gate** (play it, with a friend, and not want to stop). The netcode/determinism rigor stays (it is a real strength); "done" stops meaning "tests pass."
|
||||
4. **Pause inventory/equipment Phases 2–4 and automation breadth** (recipe/throughput). They are more breadth on systems whose payoff is combat power — premature while combat is hollow. Redirect to the combat-depth track. The shipped Phases 0–1 stand; only the forward phases pause.
|
||||
5. **The combat-depth track** ([[Path_to_Fun]], MC-1…MC-6) is the next work, designed this session via a multi-agent pass (5 design lenses grounded in real games + the actual DOTS code → synthesis → 3 adversarial critics: netcode-feasibility / solo-scope / fun, all "go-with-changes"). Keystone: a **dodge + a committed, whiff-punishable enemy + a readable telegraph** = the smallest "fight in a box" that turns stand-and-click into a conversation. The dash is the *answer*, the committed lunge is the *question*; they ship together.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The braid is the highest-leverage design change** and costs less than half of what's already built — most parts exist; they're pointed at a ledger instead of at each other.
|
||||
- **Pillar ordering changes; no code is deleted.** [[Pillars]] revised to name combat the primary braided verb + the depth-before-breadth rule; [[Identity]] gains the braid section. The automation/base records ([[DR-014_M6_Build_Structures_Automation_Foundation]] / [[DR-020_M7_Automation_Production_Chains]]) stand — their role becomes "support the fight."
|
||||
- **Combat was never play-tuned**, so MC-1 onward validates by PLAYING, not tests. The team's reflex is correctness; the discipline now is feel.
|
||||
- **Two MC-1 netcode blockers are pre-caught** (must be honored at code time): (a) the dash i-frame must negate damage per-`DamageEvent` against the tick it was authored (`HealthApplyDamageSystem` runs in the predicted group and drains the prior-tick melee event) — stamp a non-replicated `uint SourceTick` on `DamageEvent`, not "is DashState active now"; (b) `CharacterControl` has no sharpness field — a flat `MoveVelocity` write ramps ("walk faster"); also drive `CharacterComponent.GroundedMovementSharpness` near-instant for the dash window. Plus the precondition: telegraphs must read off a **replicated absolute-tick countdown** (`AttackWindup`-style `[GhostField] uint`), not interpolated motion (~100ms late). Details in [[Path_to_Fun]].
|
||||
- **Falsifiable / owner-revisitable:** if, building the braid, the factory turns out to be the operator's true love, re-cut around automation-as-heart (Factorio/Dyson model, combat as defense). The combat-first call is a recommendation the operator can reverse.
|
||||
- **Refined 2026-06-08 (same session)** via a second multi-agent pass (4 deepen lenses → synthesis → 3 code-grounded adversarial critics, all go-with-changes): [[Path_to_Fun]] is now split into a **committed Path A** (MC-0/MC-1/MC-4/EB-1/EB-2/END-1/END-2 — the minimal path to *fight-fun + braided-with-stakes + a win/lose condition*, a shippable game-with-a-point) and a **provisional, NOT-scheduled Path B** forever-track, with a **mandatory logged Decision Gate** after END-2 before any Path B work (the enforcement teeth of depth-before-breadth). The critics' code audit added a **third `DamageEvent` stamp site** (`TurretFireSystem`) to the i-frame `SourceTick` fix and required `DashState` carry an explicit `StartTick` (the window-start the negation compares against, via `NetworkTick` — the strike is appended a tick earlier in a different system group than the drainer). Net-new replicated state stays minimal — structure `Health` (EB-1) and `CoreIntegrity`/`RunPhase`/`RunOutcome` bytes on the existing global ghost (END-1/2). Ten operator forks are catalogued in [[Path_to_Fun]] (top: lose-severity, enemy-aggro, charge-cadence).
|
||||
- Supersedes the implicit "four co-equal pillars" framing of [[Pillars]]; sets [[Path_to_Fun]] as the new north-star roadmap. [[Milestones]] remains the historical record.
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
id: DR-029
|
||||
title: Path A forks locked + the present-the-forks ritual (no auto-deciding gameplay)
|
||||
status: accepted
|
||||
date: 2026-06-09
|
||||
tags:
|
||||
- decision
|
||||
- design
|
||||
- roadmap
|
||||
- combat
|
||||
- process
|
||||
permalink: gamevault/07-sessions/decisions/dr-029-path-a-fork-locks
|
||||
---
|
||||
|
||||
# DR-029 — Path A Forks Locked + the Present-the-Forks Ritual
|
||||
|
||||
## Context
|
||||
|
||||
[[Path_to_Fun]] (refined per [[DR-028_Combat_Primary_Verb_Depth_First]]) carried ~20 open forks — the committed **Path A** could not be built without locking the ones that shape its feel. The operator made a **process correction**: gameplay-design forks are the operator's to call — **present each fork with a recommendation and let the operator decide; never auto-decide a gameplay question or mark a default "official" without an explicit okay.** (An attempt to auto-lock every fork via a workflow was halted mid-run.) The forks were then worked through interactively.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. Path A forks are LOCKED** (2026-06-09), via a present-the-forks exercise. The set (also tabled in [[Path_to_Fun#Locked decisions (Path A)]]):
|
||||
|
||||
- **MC-1 dash:** free aim during the dash · whole-window i-frames (the recovery tail punishes spam).
|
||||
- **MC-1 Charger:** a new Husk **variant prefab** (distinct silhouette + telegraph), not a brain-byte.
|
||||
- **MC-4 cleave:** its **own button** + its **own cooldown** (dash→cleave→shoot stays live).
|
||||
- **EB-1 aggro:** Husks **push for the base/structures** (attacking players in the way) — you defend; live singleton.
|
||||
- **EB-1 / END-1 persistence:** a **wounded base persists** across save/quit (structure HP + Core integrity in SaveData v3).
|
||||
- **EB-2 ammo:** **turret ammo only** (server-only) from the factory; player abilities stay free for now. **One** munition type ("Charge"). Run-dry = **soft-fail** (turrets go quiet).
|
||||
- **END-1 lose:** **soft loss** — a breach drains the shared ledger / damages structures, the siege ends, the base persists wounded (Tuning byte). A breach drains the **Core bar only**; structure destruction stays EB-1's job.
|
||||
- **END-2 win:** the meter fills from **BOTH** surviving sieges AND Aether deposited at the Engine (the operator's one non-default pick — the stronger braid; two server-only single writers summed into `GoalProgress.Charge`). Winning = **keep playing** (the base is yours; NG+/endless is the later END-5). Final beat = **one big escalating wave** (boss deferred).
|
||||
- **`EnemyStatus`** co-op damage-amp stays in **MC-5**.
|
||||
|
||||
Live-singleton picks remain tunable at playtest (a lock is a starting default, not a cage). The one departure from the safe default — **charge cadence = both** — wires the win condition into the economy braid and costs END-2 a little extra deposit-charge wiring.
|
||||
|
||||
**2. The fork-locking ritual is a standing process rule.** Path B forks stay **open**; before building any Path B milestone (chosen at the [[Path_to_Fun#The Decision Gate (MANDATORY STOP after END-2)|Decision Gate]]), **its forks are locked first via this same present-the-forks exercise.** No auto-deciding gameplay-design questions.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Path A is concretely specified — Claude can build MC-0 + the MC-1 code/tests autonomously; the first genuine operator touchpoints are the **Burst-affecting `CharacterProcessor` edit** (focused editor) and the **MC-1 fun-gate** (feel + ideally a friend).
|
||||
- [[Path_to_Fun]]'s "Open decisions" section is replaced by **Locked decisions (Path A)** + the ritual note; [[Backlog]] updated.
|
||||
- Reversible by the same ritual — live-singleton locks flip at playtest; structural locks (cadence-both, persistence v3, turret-only ammo) are committed shapes a re-run can revisit.
|
||||
- Reinforces [[DR-028_Combat_Primary_Verb_Depth_First]]'s depth-before-breadth: Path B is not pre-decided, so breadth cannot creep in via a stale default.
|
||||
Reference in New Issue
Block a user