Initial Combat Implementation
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
---
|
||||
id: DR-003
|
||||
title: M2 combat netcode architecture — predicted projectiles, server-authoritative auto-target & damage, swept hits
|
||||
status: accepted
|
||||
date: 2026-05-31
|
||||
tags:
|
||||
- decision
|
||||
- netcode
|
||||
- combat
|
||||
- prediction
|
||||
permalink: gamevault/07-sessions/decisions/dr-003-m2-combat-netcode-architecture
|
||||
---
|
||||
|
||||
# DR-003 — M2 Combat Netcode Architecture
|
||||
|
||||
## Context
|
||||
|
||||
M2 builds the [[Milestones|combat foundation]]: directional ability fire + deterministic soft auto-target + server-authoritative health/damage, on Unity 6.4.7 / Netcode for Entities 1.13.2. Several architecture forks materially shape every future combat system, so they were settled up front (operator-approved) and validated against the official ECS samples + live `unity_reflect`.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Projectiles are owner-predicted ghosts with client predicted-spawn.** The firing client predict-spawns the projectile immediately and a custom classification system pairs it with the server's authoritative ghost by a `SpawnId = (ownerNetworkId << 16) | absoluteFireCount`. Pattern lifted verbatim from HelloNetcode `02_PredictedSpawning` (`ProcessFireCommandsSystem` + `GrenadeClassificationSystem`). Spawning is gated on `NetworkTime.IsFirstTimeFullyPredictingTick` so rollback never double-spawns; the absolute fire count is read from `InputBufferData<PlayerInput>.GetDataAtTick(ServerTick)` (the live `InputEvent.Count` is only the per-tick delta).
|
||||
2. **Fire is a netcode `InputEvent`** on `PlayerInput` (`[GhostField]`). The client gather (`PlayerInputGatherSystem`, managed `SystemBase`) resets it each frame and calls `.Set()` on the press edge; netcode latches the monotone absolute count into the command buffer.
|
||||
3. **Auto-target is server-authoritative, resolved at the fire tick.** The client predicts the shot along raw aim; the server runs the in-cone nearest-target search (`AutoTarget.Resolve`, deterministic, ties by index) and writes the assisted direction into the projectile's `[GhostField] Direction`, which reconciles the client's predicted projectile. Because the assist arc is soft (small), the reconciliation correction is minor.
|
||||
4. **Damage is server-only.** `Health.Current` is a `[GhostField]` (replicated for display/reconcile); hits and destruction run only on the server (`ProjectileDamageSystem` → `DamageEvent` buffer → `HealthApplyDamageSystem`). Clients do not predict hits/destruction (matches the sample). No Unity Physics — hits are distance/cone math (Physics arrives at M4).
|
||||
5. **Projectile hit detection is a swept segment-vs-sphere test**, not a point check (see Consequences).
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Predicted-spawn classification cannot be `[BurstCompile]`d on 1.13.2.** Bursting the classifier trips a Burst *internal compiler error* (type-hash resolution failure) on the cross-assembly generic `SnapshotDataBufferComponentLookup.TryGetComponentDataFromSnapshotHistory<T>()`. `ProjectileClassificationSystem` is therefore a non-Burst `ISystem` (it only runs when ghost spawns are *received* — a cold path). Note: in 1.13.2 that method takes `ref DynamicBuffer<SnapshotDataBuffer>` (the official sample's by-value form is older).
|
||||
- **Predicted projectile + server auto-target = a deliberate reconciliation curve.** The firing client briefly sees its projectile along raw aim until the server's auto-targeted `Direction` replicates. Acceptable for soft assist + co-op PvE. Upgrade path if it ever feels off: run auto-target predictively (needs deterministic, stable-id target selection on both worlds).
|
||||
- **Point-distance hit tests tunnel** — a fast projectile (or any projectile while the server tick-batches under load) steps past a target's hit radius in a single tick. Caught at runtime (a speed-25 shot missed; speed-3 hit). Fixed with a swept test over each tick's travel segment (`[curPos - dir*speed*dt, curPos]`), `ProjectileDamageSystem` ordered `[UpdateAfter(ProjectileMoveSystem)]`, earliest-along-path target wins. Regression-tested in `ProjectileDamageSystemTests`.
|
||||
- **Deferred** (revisit at the noted milestone/trigger): live interactive fire validation (press Fire in a focused editor — Input System ignores injected device input while the Game view is unfocused); predicted-spawn *client* feel; mouse-cursor aim for KBM (needs a camera ground-ray rig); player death/respawn (M2 clamps player HP ≥ 0, only dummies despawn).
|
||||
|
||||
Mirrors the server-authoritative + input-only-clients pillar from [[Pillars]]; extends the M1 predicted player slice ([[2026-05-30_M1_Player_Slice]]).
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: DR-004
|
||||
title: M3 data-driven abilities & modifiers — blob definition DB, replicated StatModifier buffer, every-tick effective recompute
|
||||
status: accepted
|
||||
date: 2026-05-31
|
||||
tags:
|
||||
- decision
|
||||
- netcode
|
||||
- combat
|
||||
- data-driven
|
||||
- prediction
|
||||
permalink: gamevault/07-sessions/decisions/dr-004-m3-data-driven-abilities-modifiers
|
||||
---
|
||||
|
||||
# DR-004 — M3 Data-Driven Abilities & Modifiers
|
||||
|
||||
## Context
|
||||
|
||||
M2 hard-coded combat/character values into baked components (`AbilityStats`, `Projectile.Speed/Damage/Range`, `PlayerMoveStats`, `Health.Max`). M3 ([[Data_Driven_Abilities]]) moves those into **authored ScriptableObject definitions baked to DOTS blob assets**, with a runtime **flat + % modifier stack** producing effective stats — server-authoritative and prediction-correct. These forks shape the whole upgrade/buff meta-game, so they were settled up front (operator-approved at intake) and validated against context7 (Entities 6.4 / Netcode 1.13.2) + the official ECS samples, then runtime-confirmed. Extends [[DR-003_M2_Combat_Netcode_Architecture]].
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Definitions → blob, not replicated.** `AbilityDefinition` / `CharacterStatsDefinition` ScriptableObjects → an `AbilityDatabaseAuthoring` baker builds a `BlobAssetReference<AbilityDatabaseBlob>` (`BlobArray` of ability/character entries, each keyed by a stable `AbilityId`/`CharacterId` byte + a `FixedString64Bytes` name) and adds an `AbilityDatabase` singleton. Baked in the gameplay subscene → streams identically into both worlds (config). Definitions are **never replicated**.
|
||||
2. **Entity/prefab refs live OUTSIDE the blob.** Blob assets don't remap entity references. The per-ability projectile ghost prefab is resolved via `GetEntity` into a **companion `DynamicBuffer<AbilityPrefabElement>{ byte Id; Entity Prefab }`** on the database singleton (entity refs in components *are* patched). `AbilityFireSystem` resolves the prefab by the firing player's `AbilityRef.Id`.
|
||||
3. **Modifiers replicate as a `[GhostField]` buffer.** `StatModifier : IBufferElementData` (`[GhostComponent(OwnerSendType = SendToOwnerType.All)]`, `[InternalBufferCapacity(8)]`) on the player ghost. **`Target`/`Op` replicate as raw `byte`** (mapped to the `StatTarget`/`ModOp` enums only in the pure math), to keep the generated serializer trivial and dodge the cross-assembly enum-codegen hazard that already de-Bursted the M2 classifier. Server is the only writer; the owner **must** receive it (`All`) or it mispredicts.
|
||||
4. **Effective stats are derived, never replicated.** `EffectiveAbilityStats` / `EffectiveCharacterStats` (plain `IComponentData`) are computed by `StatRecomputeSystem` (Burst, `PredictedSimulationSystemGroup`, `[UpdateBefore]` Aim+Move so all consumers read fresh) folding blob base + the replicated modifier buffer via the pure `StatMath.Apply` — `effective = (base + Σflat) × (1 + ΣpercentAdd) × Π(1 + percentMult)`.
|
||||
5. **Recompute EVERY predicted tick, no dirty flag.** Both inputs (blob base, replicated buffer) are restored on rollback; the `Effective*` components are **not** in the ghost snapshot, so a dirty-flag/change-filter would leave them stale across reprediction. Unconditional per-tick recompute over a tiny buffer is cheap and unconditionally prediction-correct.
|
||||
6. **Projectile stats are snapshotted at fire.** `AbilityFireSystem` reads `EffectiveAbilityStats` and writes effective Damage/Speed/Range into the spawned `Projectile`, so the downstream move/damage systems are unchanged and predicted+server projectiles match (both folded the same replicated modifiers this tick).
|
||||
7. **Upgrade source = both.** A real server-authoritative pickup (`UpgradePickup` interpolated ghost + subscene spawner; `UpgradePickupSystem` in `SimulationSystemGroup`, NOT predicted, overlap-grants a modifier via `ecb.AppendToBuffer` and despawns), plus an editor-only `DebugModifierInjectionSystem` running in the **server** world (static pokes → applied to the local player; a client-side append would be stomped by the next snapshot).
|
||||
8. **`MaxHealth` single source = `CharacterStatsDefinition`.** `PlayerAuthoring` references the SO and seeds `Health.Current=Max` from it; `EffectiveCharacterStats.MaxHealth` (base+mods) is the runtime clamp ceiling in `HealthApplyDamageSystem` (no auto-heal). `PlayerAimSystem` left as-is (it snaps rotation and ignores turn rate; `TurnRate` kept in effective stats for future rate-limited turning).
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Ghost-buffer-on-owner-predicted-ghost is the load-bearing surface.** Validated at runtime: a server-granted `+50 Damage` / `+50% MoveSpeed` produced **identical** `effDmg=70` / `effMove=9` on server **and** owner-predicted client — replication + recompute agree, no divergence. This held **even under server tick-batching** (the M2 stress condition), confirming the every-tick recompute choice.
|
||||
- **`readonly` blob methods are a footgun.** A `readonly` struct method that calls a non-readonly member on a `BlobArray` field forces a defensive copy of the array → its relative-offset pointer breaks → the array reads empty. Caught by a unit test (lookups returned false for present ids); fix = drop `readonly`, always reach the blob through `ref blob.Value`.
|
||||
- **Zero code per new ability.** Two sample abilities (FastLight, SlowHeavy) are pure SO data; swapping `AbilityRef.Id` (a `[GhostField]`) re-points the same fire code at different blob stats. Runtime-confirmed: cycling Primary→FastLight gave `dmg 20→8, spd 25→40, cd 12→5` on both worlds with no code path change.
|
||||
- **Pickup is interpolated (server-authoritative), like the dummy.** Clients see/despawn it; the grant flows server → snapshot → owner. The debug hook is in-editor single-process only; a standalone-server debug path would need an `IRpcCommand` (deferred).
|
||||
- **Deviations from the proposed design** (refined at build time): prefab refs → companion buffer (not the blob); recompute every-tick (not the doc's dirty-flag lean); raw-byte enum replication; `MaxHealth` sourced from the SO not a separate authoring number.
|
||||
|
||||
Mirrors the server-authoritative + deterministic pillar from [[Pillars]]; the modifier framework is the foundation the full ability/loadout/upgrade system builds on.
|
||||
Reference in New Issue
Block a user