DRIFTLANDS co-op survival on drifting tides — studio progress

ADR-0015: Combat Model — ICombatState Ownership, the Dual-Run Player Machine, Strike Resolution, Enemy Damage Phase 0, Downed/Revive, and the Durability Events

Status

Accepted

Accepted 2026-08-28 — architecture-review-2026-08-28.md (verdict CONCERNS; review amendments applied before acceptance).

Date

2026-08-28

Engine Compatibility

Field Value
EngineUnity 6.3 LTS (6000.3) · NGO 2.x · C# (pure logic in Driftlands.Rules)
DomainCore (with one Networking touch-point: which combat variables replicate — the representation is ADR-0008's)
Knowledge RiskLOW for everything this ADR decides — engine-free C#. HIGH for the replication rows it names but does not decide (NGO 2.x, ADR-0008). Specialist-verified facts used here: NetworkTickSystem.Tick is a bare event Action with no documented subscriber-ordering guarantee (hence one orchestrator, §1); clientId is documented as unstable across reconnects and may be reassigned to a different player (NGO 2.4 session-management manual — hence PlayerKey, §1); enums are natively supported NetworkVariable<T> types (no custom serializer for EnemyPhase); MidpointRounding.AwayFromZero is BCL-standard and identical under Mono/IL2CPP but silently becomes banker's rounding under Burst (Unity-confirmed bug) — Rules cannot reference Burst, and must never be moved into a Burst path.
References Consulteddocs/engine-reference/unity/VERSION.md, modules/networking.md (1.x-flavoured — not relied on); live: NGO 2.5 "NetworkTime and ticks", 2.7 NetworkTickSystem API, 2.6 NetworkUpdateLoop, 2.4 NetworkVariable + "Session management"; ADR-0001/0002/0003/0005/0006/0007; design/gdd/combat-system.md (complete), harvesting Rules 1–2, enemy-ai, netcode Rule 5, movement Rules 3–6, crafting Rule 9/F3
Post-Cutoff APIs UsedNone. MidpointRounding.AwayFromZero (BCL) for every round().
Verification Required(1) With ADR-0008's tick rate, ResolveTick = IntentTick(host-stamped) + ceil((windup + active) / tickDt) sits ≥ 1 RTT after the owner's Windup entry at 140 ms RTT (the ≥ 0.18 s guard assumes tick granularity ≤ 20 ms — re-check if not 50 Hz). Two standalone builds under the Multiplayer Tools Network Simulator, not MPPM (ADR-0002 Verification 5). (2) Same rig: the host mirror's three legality verdicts (§2) agree with the owner's except where EC16 applies. (3) EditMode host-step harness asserts the four-step host order (§1) and that a PendingEnemyDamage produced in step 3 is consumed by the same tick's step 1b — our own call order; no NGO probe measures it.

Documents Amended by This ADR

Document Amendment
ADR-0002CommandKind gains four members: StrikeRevoke, DashStarted, ReviveStart, ReviveCancel. IntentTick is host-stamped at envelope receipt for every intent — supersedes the worked HarvestResolveCommand's / intent tick + … / parenthetical (harvesting inherits the same rule). HostContext is built once per host tick and used by steps 2–4 (was "once per drain").
ADR-0001§4: Advance has exactly one caller — HostTickOrchestrator step 1, once per NetworkTickSystem.Tick; the Awaitable timer is Presentation-role / IPreloadSchedule-only (architecture review C1, 2026-08-28).
ADR-0003ICombatState gets its shape (§Key Interfaces). IInstanceMetadata is declared to expose IGearHooks (the trailing comment becomes a type relationship). ReasonCode gains NotDowned, AlreadyBeingRevived, ReviverIneligible. StrikeIntent/SwingId/TargetKind are shared vocabulary in Driftlands.Data (owned by neither combat nor harvesting).
docs/architecture/architecture.md§2 Combat row; §3.1 host frame path gains CombatState.Tick before EnemyAI.Tick under one orchestrator; §4 IStrikeResolver retired, ICombatActor → ICombatState + IPlayerCombatView, IEnemyCombat.Meter : float + Phase, StrikeIntent home stated; Required-ADR row 15 written.
design/quick-specs/player-movement-2026-08-27.mdRule 5 gains the Downed carve-out: crawl is a locomotion mode (downedCrawlSpeed knob), not a modifier.
design/gdd/combat-system.md (proposed, via /quick-design before the first combat story)Tuning table gains maxTargets (default 1), reviveReach (1.5 u, 1.0–2.5), and a 4th revive cancel cause (reviver leaves reach); Rule 6's home heal rate recorded as an open question (instant at MVP).

ADR Dependencies

Field Value
Depends OnADR-0002 (ICommand/HostContext/drain phases 1–1b–2–3–4; arrivalSeq; Now), ADR-0003 (ICombatState member, the two event records, IHostPositions, IGearHooks, Rules.Combat home), ADR-0007 (InputFrame edges and Revive.HeldFor; edge consumers run in Update), ADR-0006 (combat.json — stage 4 rows already mechanised)
EnablesADR-0008 (replication rows: player Hp/State/BleedOutRemaining, enemy Phase; intent encodings; late-join actor snapshot; the concrete source of PlayerKey), ADR-0009 (dash and knockback are movement-rail displacements this ADR requests; downedCrawlSpeed mode), ADR-0012 (EnemyAttack → PendingEnemyDamage contract, IEnemyCombat, attack shapes), ADR-0014 (HUD data from IPlayerCombatView and the two records only)
BlocksEvery combat story; the harvest-resolve story (shared StrikeIntent); EnemyAI attack stories
Ordering NoteArchitecture Required-ADR row 15 = ADR-0015. Accept after ADR-0002/0003/0007. The model is decidable now; ADR-0008 decides wire shapes and cannot invalidate it.

Context

Problem Statement

HostContext.Combat : ICombatState is the largest surface in the architecture with no owner: who holds HP, state and stagger per actor; how the owner-predicted verb machine relates to the host's authoritative HP/Downed/Dead; how a unified strike intent becomes a StrikeResolveCommand with a deterministic, host-owned resolution instant; how EnemyAI's attack cycle becomes the PendingEnemyDamage list phase 0 applies; how the revive channel, session-scoped Downed state and home stabilisation work across a rejoin whose clientId changes; and who raises SwingLanded/DamageAbsorbed. The combat GDD fixes every rule; this ADR fixes the mechanism — and names, rather than decides, the three design values the GDD lacks.

Constraints

Requirements

Decision

Alternative A: Driftlands.Rules.Combat holds the pure model — PlayerCombatMachine (dual-run), HitPipeline (F1–F5 + targetIsValid), ActorStateRules, the commands StrikeResolve/StrikeRevoke/DashStarted/ReviveStart/ReviveCancel, and EnemyAttackResolver; Driftlands.Runtime.Combat.CombatState : ICombatState is one host-owned actor table keyed by PlayerKey (players) and EnemyId (enemies), the only writer of HP/state/meters; a single HostTickOrchestrator subscribes once to NetworkTickSystem.Tick and runs tide Advance → CombatState.Tick → EnemyAI.Tick → Drain in sequence; the owner runs its own PlayerCombatMachine from InputFrame for prediction; players' Hp/State/BleedOutRemaining and enemies' Phase are the only replicated combat variables (representation → ADR-0008); enemies' HP and meters are host-only.

1. The actor table and the host tick

PlayerKey    = session-stable identity (Steam ID or a client-persisted GUID sent in the ConnectionApproval payload — SOURCE: ADR-0008 — a client-persisted GUID carried in the ConnectionApproval payload via `IPlayerIdentity`; Steam ID provider at ship (2026-09-11); existence + stability: this ADR). clientId is a transport handle mapped to PlayerKey on connect, remapped on rejoin; never a row key.
ActorId      = PlayerId(PlayerKey) | EnemyActorId(SocketId socket, ushort index)   // renamed by ADR-0012 (Data.EnemyId is the archetype key)
PlayerActor  { Hp, State, VerbPhaseEndsTick, DashCooldownEndsTick, BleedOutEndsTick?, ReviveChannel?{Reviver:PlayerKey, EndsTick}, OnLand:SocketId?, Equipped:{Weapon:InstanceId?, Armor:InstanceId?}, RevokedSwings:set<SwingId> }
EnemyActor   { Hp, Phase, PhaseEndsTick, StaggerMeter, LastHitTick, ImmuneUntilTick, Interruptible, QueuedStagger, CurrentAttack?, Target:PlayerKey?, OnLand, KnockbackAccumulator }

2. The verb machine — one pure type, two instances, three questions

PlayerCombatMachine.Step(state, inputs, nowTick, phases) → (state', events) in Rules.Combat. VerbInputs is plain data {DashPressed, StrikePressed, RevivePressed, ReviveReleased, Downed, Dead, UiCaptured}; PhaseConstants is resolved by the caller from the swing kind — combat's swingWindup/Active/Recovery for an enemy-kind swing, harvesting's own constants (HarvestConfig) for a node-kind swing (harvesting Rule 1: independently owned) — Step never reads a bare CombatConfig for a node swing.

Transitions are the GDD table verbatim (Idle→Dashing on dash off-cooldown, i-frames on, cooldown from dash start; Idle→Windup on strike; Windup→Dashing revokes the swing; Windup→Active→Recovery→Idle by timers; Active uncancellable; Hurt is an overlay flag). Downed/Dead suppress Strike/Dash/Demolish inside Step (ADR-0007: the adapter never gates on combat state); UiCaptured blocks verb starts.

3. Strike resolution — StrikeResolveCommand

4. Enemy attacks — phase 0

Host step 3 (EnemyAI.Tick) advances each EnemyActor; on Telegraph → Active it calls EnemyAttackResolver.Resolve(enemy, attackDef, players) → PendingEnemyDamage[] for every player inside the attack's shape on that land whose host mirror is not Dashing (i-frames, host view — EC16). Phase 1b applies each: DR = ctx.Gear.DamageReduction(armor) if an armor instance is equipped else 0.0; damageTaken = HitPipeline.DamageTaken(incoming, DR); HP clamped to 0 first (EC9); ActorStateRules.OnZeroHp(...); DamageAbsorbed(...) buffered — all before any player command (AC-32). SoftClose (step 1) cancels attacks mid-Telegraph/Active on closed lands (AC-34): EnemyAI reads the soft-closed set the drain publishes and never resolves those. IEnemyCombat.EnterTelegraph(attackId) is the only EnemyAI→combat call; the density cap is EnemyAI's check before calling; Phase is exposed for it. EnemyActor.Phase is the one replicated enemy variable (EC17).

5. Downed, Dead, revive, home — ActorStateRules (pure) + CombatState.Tick

6. Events and presentation

SwingLanded/DamageAbsorbed are raised only from the drain's buffered flush (phase 4) — never from prediction, never from Tick. Crafting drains durability (F3); presenters/HUD get DamageDealt/DamageTaken, StaggerFired, Lethal, Downed, Dead. Predicted feedback from the owner machine's events; confirmed feedback (impact, local hitstop, damage number, stagger/knockback, death) from the records. A rejected strike produces nothing.

Architecture Diagram

 OWNER (Update)                                              HOST — HostTickOrchestrator, sole Tick subscriber
 InputFrame → VerbInputs → PlayerCombatMachine.Step ─┐        (1) tide Advance
   Windup: Referent.Choose → StrikeIntent ═══════════╪═(one channel, ordered per client)═► envelopes: IntentTick = Now on receipt
   Dashing: DashStartedCommand · Windup→Dash: StrikeRevokeCommand · Revive: ReviveStart/Cancel
                                                              (2) CombatState.Tick — bleed-out, enemy phases, revive complete, home heal
                                                              (3) EnemyAI.Tick — EnterTelegraph · EnemyAttackResolver → PendingEnemyDamage (mirror: not Dashing)
                                                              (4) Drain: 1 SoftClose · 1b apply PendingEnemyDamage (F2 → OnZeroHp)
                                                                         2 envelopes (mirror Step · StrikeResolve @ ResolveTick: ArcTest×maxTargets, F1, ApplyHit F4)
                                                                           └ end of step 2: F5 argmax → displacement request (ADR-0009)
                                                                         3 teardown release · 4 flush SwingLanded / DamageAbsorbed → Crafting F3, Presenters, HUD
 replicated (ADR-0008 rows): Player Hp/State/BleedOutRemaining · Enemy Phase — nothing else. Rows keyed by PlayerKey, never clientId.

Key Interfaces

namespace Driftlands.Data   // shared vocabulary — owned by neither combat nor harvesting (architecture §4 shape retained)
{
    public readonly record struct PlayerKey(ulong Value);      // session-stable; source: ADR-0008
    public readonly record struct StrikeIntent(uint SwingId, Vector2 Aim, TargetKind Kind, NodeId? Node);   // NO tick — host stamps IntentTick on receipt
    public enum TargetKind : byte { Enemy, Node }
}

namespace Driftlands.Rules.Combat
{
    public enum PlayerState : byte { Idle, Dashing, Windup, Active, Recovery, Downed, Dead }
    public enum EnemyPhase  : byte { Idle, Telegraph, Active, Recovery, Stagger, Dead }
    public readonly record struct VerbInputs(bool DashPressed, bool StrikePressed, bool RevivePressed, bool ReviveReleased, bool Downed, bool Dead, bool UiCaptured);
    public readonly record struct PhaseConstants(long WindupTicks, long ActiveTicks, long RecoveryTicks, long DashTicks, long DashCooldownTicks);  // resolved by kind: combat's or harvesting's
    public readonly record struct PlayerVerbState(PlayerState State, long PhaseEndsTick, long DashCooldownEndsTick, bool Hurt, uint? PendingSwingId);
    public static class PlayerCombatMachine
    {   /// Pure. Owner (from InputFrame) and host mirror (from the intent stream). Downed/Dead suppress verbs here.
        public static (PlayerVerbState next, VerbEvents events) Step(PlayerVerbState s, VerbInputs i, long nowTick, PhaseConstants phases); }

    public static class HitPipeline
    {
        public static bool ArcTest(Vector2 attacker, Vector2 aim, Vector2 target, CombatConfig cfg);             // F3 geometry incl. +12° / +0.2 u
        public static int  DamageDealt(int weaponBase, float damageModifier);                                    // F1, AwayFromZero (never Burst)
        public static int  DamageTaken(int incoming, float damageReduction);                                     // F2, floored 0
        public static (float meter, bool fired, bool queued) Stagger(float meter, float contribution, long lastHitTick, long nowTick, long immuneUntil, EnemyPhase phase, CombatConfig cfg); // F4 + EC11/EC18/AC-19
        public static (PlayerKey owner, Vector2 vector) Knockback(IReadOnlyList<(PlayerKey owner, Vector2 v)> sameTick); // F5 argmax, lowest key on tie
    }
    public static class ActorStateRules
    {   public static PlayerState OnZeroHp(bool alliesAliveOnLand, bool atHome);   public static int ReviveHp(int maxHp, float fraction); }

    // Commands — all on HostCommitChannel (CommandKind: StrikeResolve, StrikeRevoke, DashStarted, ReviveStart, ReviveCancel)
    public sealed record StrikeResolveCommand(uint SwingId, Vector2 Aim, long ResolveTick) : ICommand;   // ResolveTick from the host-stamped IntentTick
    public sealed record StrikeRevokeCommand(uint SwingId) : ICommand;                                     // mirror + RevokedSwings; no-op after resolution (hit stands)
    public sealed record DashStartedCommand(uint DashId) : ICommand;                                       // mirror Idle/Windup→Dashing; CancelRevive(reviver, ReviverDashed)
    public sealed record ReviveStartCommand(PlayerKey Target) : ICommand;
    public sealed record ReviveCancelCommand(PlayerKey Target) : ICommand;
    public readonly record struct PendingEnemyDamage(PlayerKey Victim, int Incoming, EnemyId Attacker, Vector2 Knockback);
    public static class EnemyAttackResolver
    {   public static IReadOnlyList<PendingEnemyDamage> Resolve(EnemyActorView e, AttackDef a, IReadOnlyList<(PlayerKey key, Vector2 pos, bool hostSeesDashing, bool alive)> players); }

    /// The two questions harvesting asks — segregated so harvesting never sees the combat surface (core-harvesting story-004; the ADR-0003 precedent IInstanceMetadata : IGearHooks). Declared in Driftlands.Rules.Commands.
    public interface ISwingerStateQuery { bool CanResolveHarvest(PlayerKey key); bool AcceptsHarvestIntent(PlayerKey key); }

    /// HostContext.Combat — owner: this ADR. Implemented by Runtime.Combat.CombatState (single writer), which also implements both ISwingerStateQuery members:
    /// CanResolveHarvest is false when Dead or Downed at RESOLUTION (harvesting Rule 9, AC-20); AcceptsHarvestIntent is true only from Idle/Move (AC-38, AC-21 — Downed has no carve-out).
    public interface ICombatState : ISwingerStateQuery
    {
        PlayerActorView Player(PlayerKey key);  EnemyActorView Enemy(EnemyActorId id);  IEnumerable<EnemyActorView> EnemiesOn(SocketId land);   // EnemyActorId — ADR-0012
        bool HostSeesDashing(PlayerKey key);                                                           // mirror view (EC16)
        void MirrorStep(PlayerKey key, VerbInputs i);                                                  // fed by StrikeIntent / DashStarted / StrikeRevoke commands
        void ApplyHit(EnemyId target, int damage, float staggerContribution, PlayerKey attacker, Vector2 knockback);   // F4 in place; knockback → accumulator
        void ApplyEnemyDamage(PendingEnemyDamage d, float damageReduction);                            // phase 1b: F2, clamp, OnZeroHp
        bool TryStartRevive(PlayerKey reviver, PlayerKey target, long endsTick);  void CancelRevive(PlayerKey target, ReviveCancelReason why);
        void RevokeSwing(PlayerKey owner, uint swingId);
    }
    public interface IPlayerCombatView { int Hp { get; } int MaxHp { get; } PlayerState State { get; } float BleedOutRemaining { get; } }   // HUD — never enemy HP
}

namespace Driftlands.Runtime.Combat
{
    public sealed class HostTickOrchestrator : NetworkBehaviour { /* sole NetworkTickSystem.Tick subscriber: Advance → CombatState.Tick → EnemyAI.Tick → Drain, one HostContext per tick */ }
    public sealed class CombatState : ICombatState { public void Tick(long now, HostContext ctx); public void EmitKnockbacks(IDisplacementSink m);   // ADR-0009: Rules.Movement.IDisplacementSink over the enemy's KinematicMover2D /* end of drain step 2 */ }
    public interface IEnemyCombat { void EnterTelegraph(AttackId id); void LeashReset(); bool Interruptible { get; } float Meter { get; } EnemyPhase Phase { get; } }   // exactly TWO EnemyAI→combat calls: EnterTelegraph and LeashReset (ADR-0012, user decision 2026-09-11)
}

Replication rows for ADR-0008 (named, not decided): PlayerActor.Hp (int), .State (enum), .BleedOutRemaining; EnemyActor.Phase (enum — natively supported); the five command encodings; the late-join actor snapshot (netcode Rule 4 "current player states" = these rows, keyed by PlayerKey); the concrete PlayerKey source (Steam ID vs client GUID in the ConnectionApproval payload — a GUID fallback keeps non-Steam local testing working).

Alternatives Considered

Alternative B: Per-actor NetworkBehaviour owning HP with its own RPCs

Alternative C: Host-only player machine, client animates blind

Alternative D: Client-timed revive

Alternative E: Replicate enemy HP/meters now

Alternative F: DashStarted as an owner-written NetworkVariable "notification"

Alternative G: Three independent NetworkTickSystem.Tick subscribers

Consequences

Positive

Negative

Risks

GDD Requirements Addressed

GDD System Requirement How This ADR Addresses It
combatRules 1–4, state table, AC-1–5PlayerCombatMachine.Step (owner instance); DashStartedCommand
combatRule 5, AC-6–9: flat stats, two hooks, AwayFromZero, bit-identicalHitPipeline over ints; IGearHooks.DamageModifier/Reduction; no other stat input exists
combatRule 7, F3, AC-10–14, AC-36: unified intent, host resolves at the fixed instant vs current positions, fixed tolerances, Docked re-sampledStrikeIntent → StrikeResolveCommand at host-stamped ResolveTick; fresh ctx reads; maxTargets (GDD value, default 1)
combatRule 8, feedback tableowner events vs the two records from the flush; hitstop local
combatRule 9, F6, EC4–8, AC-26–28, AC-42: Downed/Dead/revive/bleed-out/home; keep everythingActorStateRules, Revive*Command, CombatState.Tick; crawl as a locomotion mode
combatRule 10, AC-11/29targetIsValid never a player
combatRule 11, F4/F5, EC11/18, AC-15–19ApplyHit + Stagger; F5 argmax at end of step 2 as a displacement request
combatRule 13, AC-32–35drain phases 1/1b; EnemyAI skips soft-closed lands; home stabilise on deposit
combatEC1–3, AC-40–41: disconnect; rejoin restoresrows keyed by PlayerKey outlive bodies
combatEC9/10/12, AC-30/37–39clamp-first; validation in ADR-0006 stage 4
combatEC15/16mirror re-sampled in Execute; HostSeesDashing in EnemyAttackResolver
combatEC17EnemyActor.Phase replicated (ADR-0008)
harvestingRules 1–2: own phase constants; referent decided once; node intents never hitsPhaseConstants by kind; Referent.Choose on the owner; Node routed to HarvestResolveCommand
enemy-aiRules 2/6, regression ACIEnemyCombat.EnterTelegraph only; Phase exposed for the cap check; resolver from step 3
craftingRule 9 / F3events from the flush only; dull → baseline via the hooks
netcode-sessionRules 2/4/5commands only (no third rail); actor snapshot rows named; PlayerKey for session-scoped state
player-movementRules 3–6dash/knockback as displacement requests; Snap(DeathRespawn); Downed = locomotion mode (amended)
ui-hudHP/party/Downed data; damage numbersIPlayerCombatView + the two records' numbers only

Deferred, with owners

Item Owner Trigger
maxTargets (default 1), reviveReach (1.5 u) + 4th cancel cause, home heal ratecombat GDD (/quick-design)before the first combat story
downedCrawlSpeed + Rule 5 carve-outmovement quick-specsame change as this ADR
PlayerKey concrete source (Steam ID vs GUID); replication representation; encodings; snapshotADR-0008playtest
Dash / knockback displacement, Snap(DeathRespawn), crawl modeADR-0009pre-playtest
AttackDef shapes, steeringADR-0012enemy simulation ADR

Performance Implications

Migration Plan

No production code. (0) Fold Driftlands.Rules.Harvest.SwingerState into PlayerState and delete the harvesting-local enum — the harvest phase machine keeps its own phases (its Dashing exists only as a revocation target) and must not grow combat's (review 2026-09-07 #4); (1) Data: PlayerKey, StrikeIntent, TargetKind; Rules.Combat pure types + F1–F5 UNIT tests (AC-7–19 verbatim); (2) PlayerCombatMachine + AC-1–5; PhaseConstants from both configs; (3) ActorStateRules + AC-26/27/30/42; (4) the five commands + EnemyAttackResolver on ADR-0002's pure drain harness (AC-31/32/33/36, accumulator invariants, late revoke); (5) HostTickOrchestrator + EditMode host-step harness; CombatState + IEnemyCombat; ADR-0002/0003 amendments applied; (6) movement quick-spec carve-out + combat GDD quick-design for the three values; (7) two standalone builds under Network Simulator: AC-14/40/41, mirror verdicts; (8) presenters/HUD (ADR-0014).

Validation Criteria

Related Decisions

Amendments — 2026-09-07 (architecture-review-2026-09-07.md)

Amendments — 2026-09-11 (ADR-0008 netcode wire)

Amendments — 2026-09-11 (ADR-0009 player transform authority)

Amendments — 2026-09-11 (ADR-0010 inventory replication)

Amendments — 2026-09-11 (ADR-0012 enemy simulation model)

Amendments — 2026-09-13 (NetcodeSession story-006b — session-scoped row store)