DRIFTLANDS co-op survival on drifting tides — studio progress

ADR-0012: Enemy Simulation Model — One Flat Host Machine, Deterministic Composition, Steering Through the Shared Mover, and the Enemy Wire

Status

Accepted

Accepted 2026-09-11. Gate 0 confirmed GO the same day by the two-machine friend playtest (production/playtests/gate-0-2026-09-11/, record production/gate-checks/gate-0-provisional-pass-2026-09-11.md); the provisional note and revisit clause this ADR was written under are discharged. Playtest observation carried to the register: occasional remote stutter at ≈ 150–180 ms RTT (interpolation tuning, ADR-0009 §6).

Date

2026-09-11

Engine Compatibility

Field Value
EngineUnity 6.3 LTS (6000.3.18f1) · NGO 2.13.2 (dynamic NetworkObject spawn/despawn, NetworkTransform Server authority, NetworkVariable<byte> with update traits) · Unity 2D Physics via ADR-0009's mover only
DomainCore (AI) + Networking (replication of enemy bodies)
Knowledge RiskLOW for the model (pure C# state machine, host-only). HIGH for the NGO surface — every fact used was read from the 2.13.2 source for ADR-0008–0011 (2026-09-11): NetworkObject.Spawn()/Despawn(true) signatures unchanged (CHANGELOG 2.10–2.13), pre-spawn NetworkVariable values ride the spawn message (WriteFieldSynchronization), NetworkVariableBase.SetUpdateTraits, NetworkTransform.AuthorityMode = Server + Interpolate on non-authority + the global InterpolationBufferTickOffset (ADR-0009 §6, RTT-adaptive). No new NGO API.
References Consulteddocs/engine-reference/unity/VERSION.md, breaking-changes.md, deprecated-apis.md; ADR-0015 (§1 actor table incl. EnemyActor row and step order, §4 enemy attacks phase 0, IEnemyCombat, EnemyAttackResolver, EnemyPhase), ADR-0009 (§1 enemy prefab, §2 knockback via IDisplacementSink, §3 Enemy↔Enemy off + the coincident-start tiebreak owed here, §6 buffer arbitration "players win"), ADR-0008 (§5 EnemyNet rows Phase/AnimFrame, §8 cadence baseline, §2 one-tick lag), ADR-0011 (registry_and_colliders_never_compared — no collider queries for pathing), ADR-0005 (IEnemySpawner.Spawn(template, variationSeed, spawnPoints, scalingContext) seam in LandSeams.cs, LandActivated/LandTornDown), ADR-0006 (enemy-ai.json, EnemyDef marker, validation rows), ADR-0003 (Rules.Enemy composition + density check assigned; purity), ADR-0010/0011 (index-table + dataHash pattern); design/quick-specs/enemy-ai-2026-08-27.md (Rules 1–11, knobs, ACs), combat-system.md Rule 11 + AC-20/25, land-dock-undock-2026-08-27.md Rule 5, harvesting-system.md Rule 2 (enemies win), art-bible.md §5.2/§5.3 + "Flipbook, not skeletal" (TR-tint-004), accessibility-requirements.md (telegraph ≥ 400 ms, silhouette-first); shipped: Runtime/Land/LandSeams.cs (IEnemySpawner, EnemyScalingContext.Empty), Data/Land/LandShapes.cs (SpawnPoint(Index, Band, Cell), LandInstance.SpawnPoints), Data/Tables/Ids.cs (EnemyId is the archetype's string key), Data/Tables/DefMarkers.cs (EnemyDef marker)
Post-Cutoff APIs UsedNetworkObject.Spawn() per enemy after LandActivated (host), Despawn(true) on death/teardown; EnemyNet variables set before Spawn(); AnimFrame.SetUpdateTraits(new NetworkVariableUpdateTraits{MinSecondsBetweenUpdates = 0.05f}); EnemyTransform : NetworkTransform (AuthorityMode = Server, ADR-0009 settings, PositionThreshold = 0.02)
Verification Required(V1) The activation burst — up to Σ guardDensityByBand enemies (≤ ~10 at MVP) spawned in one drain's deferred-effect window — is one CreateObjectMessage per object per client (specialist, NetworkSpawnManager.SendSpawnCallForObject ~1359–1367; NGO does not merge spawns, the transport may pack them); each carries the pre-spawn variables and InternalOnSpawned resets their dirty state so no duplicate delta follows the same frame (NetworkVariableBase.cs ~148–167). Asserted: message count == spawn count, each ≤ MaxPayloadSize. (V2) AnimFrame at 20 Hz traits: MinSecondsBetweenUpdates gates sends only (NetworkVariableBase.CanSend ~287–301: last value wins, host writes every frame are free) — a client sees ≥ 15 distinct frames/s during a 1 s flipbook. (V3) Teardown order: the enemy sweep on EvictionExpired despawns every enemy on the socket before ADR-0005's LandNet despawn (which LandTornDown follows); a client handles either arrival order (the view's parent is not LandNet); a mid-linger Dead enemy is swept too. (V4) Telegraph timing at 50 Hz: a 400 ms telegraph is exactly 20 ticks host-side; combat AC-25 (≥ 330 ms visible on a 140 ms-RTT client) is the friend-playtest measurement (provisional). (V5) The no-progress de-aggro fires within deAggroSecs ± 1 tick for an enemy steering into a cliff (PlayMode, host).

Revisit clause (Gate 0)

§5 cadence/threshold and §3's telegraph timing are re-tuned by amendment from the playtest CSV + warp counter under ADR-0009's arbitration (players win; enemies get SmoothDampening, never a larger global buffer). Nothing else here is feel-dependent.

Documents Amended by This ADR

Document Amendment
ADR-0015The per-actor enemy identity EnemyId(landSocket, index) is renamed EnemyActorId(SocketId Socket, ushort Index) — Data.EnemyId is already the archetype's string key (Ids.cs). Closed list of call sites that mean the actor and change type (TD C7): `ActorId = … \EnemyActorId, the EnemyActor row key, ICombatState.Enemy(EnemyActorId), ApplyHit(EnemyActorId target, …), PendingEnemyDamage.Attacker : EnemyActorId, §3 step 2's angle tiebreak on EnemyActorId.Index; EnemiesOn(SocketId) unchanged. Data.EnemyId in any of those positions after the rename is a compile-time error the combat epic sweeps in one pass. Single-writer sentence (§1) gains a fourth site (TD C2): the deferred-spawn flush (phase 4) for AddEnemy/RemoveEnemiesOn only — row creation/destruction, never a field write. Phase advance is step 2 (TD C3): CombatState.Tick advances enemy phase timers and its Telegraph → Active transition fires EnemyAttackResolver (§4's "host step 3 advances" wording is superseded); EnemyActor.Target moves to the AI's BrainState (combat never reads a target — EnemyAttackResolver resolves "every player in the shape"); IEnemyCombat gains LeashReset() (HP to max, stagger meter and immunity cleared) — the boundary is now exactly two calls, EnterTelegraph(attackId) and LeashReset(), and the registry entry enemy_combat_boundary is revised accordingly (user decision 2026-09-11); ICombatState gains AddEnemy(EnemyActorId, EnemyId archetype) / RemoveEnemiesOn(SocketId) for spawn/teardown (single-writer: called only by EnemySpawner`).
ADR-0002§5 step 4: QueueSpawn/QueueDespawn are callable from tick steps 1–3 through the tick's HostContext (as ADR-0009 did for Broadcast); flush order: tick-step requests first, then command requests.
ADR-0008§5 enemy rows decided: EnemyNet { Phase, AnimFrame (0.05 s traits), Archetype (ushort index), ActorId }, EnemyTransform Server-auth at tick rate with PositionThreshold 0.02; dataHash appends the enemy index table (items, structures, enemies — fixed order, ADR-0011); no new OutcomeKind (stagger/death feedback rides SwingLanded{Victim, StaggerFired, Lethal}, ADR-0003).
ADR-0009The coincident-start separation tiebreak owed by §3 (TD C6) is delivered (§4 here): deterministic on EnemyActorId.Index, never random. EnemyMotor's driver contract over KinematicMover2D is fixed (§4).
ADR-0011Honoured: Runtime.Enemy never queries a collider; movement goes through the sweep only (fence Ai_NoColliderQueries).
ADR-0005IEnemySpawner (shipped seam) is implemented by EnemySpawner (§2); the enemy sweep subscribes to EvictionExpired (before the LandNet despawn) and row removal to LandTornDown (V3); EnemyScalingContext stays empty (Rule 11).
ADR-0006enemies.json (EnemyDef: archetype params + attacks[]) and enemy-ai.json (AiConfig knobs) join RequiredFiles (+ both TestJson fixtures); validation rows: combat AC-20 (telegraphMs ≥ 400 per attack — hard reject), guardDensityByBand monotonic (warning → reject on non-monotonic), deep spawn points > shore spawn points per template (warning), separationRadius finite/in range.
design/quick-specs/enemy-ai-2026-08-27.mdRule 4's heal-to-full crosses the boundary as LeashReset() (two calls, named); the forward ref "AI tick replication/relevancy → netcode ADR" closes (§5: one NetworkObject per enemy, tick-rate transforms, no relevancy — ADR-0008). Regression AC "AI never calls anything but EnterTelegraph" becomes "nothing but EnterTelegraph and LeashReset".
architecture.mdRequired-ADR row 12 ✅; §2 EnemyAI row loses its ⚠️; §4 IEnemySpawner signature unchanged.

ADR Dependencies

Field Value
Depends OnADR-0015 (actor table, phases, step 3, IEnemyCombat, resolver), ADR-0009 (mover, displacement, matrix), ADR-0008 (variables, cadence, dataHash), ADR-0005 (spawn seam, lifecycle events), ADR-0006 (data + validation), ADR-0003 (Rules.Enemy, purity), ADR-0011 (collider rule)
Enablesthe EnemyAI epic (every story); combat's enemy-attack stories (the resolver has actors to resolve); land story 009's LandTornDown enemy teardown; the feedback presenters' telegraph VFX (phase-driven)
BlocksEnemyAI epic; combat enemy-attack stories; IEnemySpawner real implementation in land lifecycle
Ordering NoteLast of the five. With this ADR Accepted, no MVP requirement routes through an unwritten ADR; /create-epics may proceed in the architecture's suggested order (NetcodeSession first).

Context

Problem Statement

Combat locked the enemy interface (Rule 11: telegraph ≥ 400 ms, stagger/knockback, per-enemy interruptibility, three archetype exemplars) and ADR-0015 locked the actor table, phases, the host step order and the one AI→combat call. The enemy-AI quick-spec named the three behaviours, the flat-machine architecture, separation steering, aggro/leash, the density cap, deterministic composition from variationSeed, no respawn and no persistence — and deferred "AI tick replication/relevancy" and the transform cadence to the netcode ADR. Row 12 asks for the simulation model: how a flat machine per enemy runs on the host, how it moves through ADR-0009's mover without querying colliders (ADR-0011), how bodies reach clients (ADR-0008/0009 baselines), how spawn/teardown ride ADR-0005's seam, and what crosses the combat boundary. Two shipped facts constrain naming: Data.EnemyId is the archetype key, and IEnemySpawner.Spawn(template, variationSeed, spawnPoints, scalingContext) already exists.

Constraints

Requirements

TR-ai-001 (host machine; clients render transform + telegraph), TR-ai-002 (steering; aggro/leash/de-aggro heal; shore leash), TR-ai-003 (cap = 1), TR-ai-004 (composition pure; no respawn; ephemeral), TR-ai-005 (density monotonic load-check; boundary calls), TR-ldu-005 (spawn contract), TR-combat-007 (telegraph = replicated Phase), TR-tint-004 (flipbook AnimFrame), combat AC-20/25 (telegraph floor; remote read).

Decision

Alternative A: one pure flat machine (Rules.Enemy.EnemyBrain.Step) parameterised by the archetype's EnemyDef — Rusher, Spacer and Bulwark are three parameter sets, not three classes — driven per enemy by the host's EnemyAI.Tick (step 3) over HostContext; composition per docking is a pure function of (template, variationSeed, spawnPoints, defs, cfg) executed by EnemySpawner : IEnemySpawner on LandActivated, which spawns one NetworkObject per enemy with EnemyNet variables set before Spawn(); movement is seek + separation forces (deterministic coincident tiebreak) integrated by EnemyMotor through KinematicMover2D, no pathfinding, no collider queries; the AI→combat boundary is exactly EnterTelegraph(attackId) and LeashReset(); the density cap is an AI-side count; bodies reach clients as EnemyTransform (Server authority, tick rate) + EnemyNet { Phase, AnimFrame, Archetype, ActorId }; enemies despawn on death (after a linger) and on LandTornDown, and nothing about them is ever saved.

1. Identity and data

2. Spawn contract — deterministic composition, one object per enemy

host LandActivated(instance, root)  (ADR-0005 activation step order; after NodeListSeeder, before/after LandNet spawn — independent)
  spawns := Rules.Enemy.Composition.Choose(instance.Template, instance.VariationSeed, instance.SpawnPoints, cfg.GuardDensityByBand, defs)
            → EnemySpawn[] { Index, SpawnPoint, Archetype (EnemyId), Facing, PatrolPath (0–2 points around the post) }   — PURE, seeded, byte-identical everywhere
  for each spawn (in Index order):
     ctx.QueueSpawn(SpawnRequest.Enemy(actorId, archetype, cell, facing))       ← buffered from tick step 1 through the tick's HostContext — the same generalisation ADR-0009 made for Broadcast: steps 1–3 may buffer deferred effects; the phase-4 flush drains them, tick-step requests first. NOT the ADR-0005 tide-edge carve-out (bounded to LandNet + Append) and NOT a host-rail table mutation — enemies are session state; invariant 4 is untouched (TD C1)
  after the loop: instantiate prefab, position = SpawnPoint.Cell.Center, EnemyNet{ActorId, Archetype, Phase = Dormant, AnimFrame = 0} set, Spawn(); CombatState.AddEnemy(actorId, archetype) (row: Hp = def band max, Phase Idle)

3. The machine — one pure step, three parameter sets

Rules.Enemy.EnemyBrain.Step(BrainState s, Senses in, EnemyDef def, AiConfig cfg, long nowTick, float tickDt) → (BrainState s', BrainActions a)
BrainState  { Mode: Dormant | Aggro | Return | Dead; Target: PlayerKey?; GuardPost: Vector2; PatrolIndex; NoProgressSinceTick; LastDistToTarget; AttackCooldownEndsTick; PendingAttack?: AttackId }
Senses      { Self: pos; CombatPhase (from IEnemyCombat.Phase); Interruptible; WasHitSinceLastTick; Players[]: (key, pos, alive, downed, onThisLand); UninterruptibleTelegraphsOn(target); LandContains(pos) }
BrainActions{ DesiredVelocity: Vector2; EnterTelegraph: AttackId?; LeashReset: bool; Displace: DisplacementRequest? (lunge) }

4. Movement — steering through the shared mover, no pathfinding, no colliders

5. Replication — one NetworkObject per enemy

State Representation Notes
positionEnemyTransform : NetworkTransform, AuthorityMode = Server, X/Y only, Interpolate = true on clients, PositionThreshold = 0.02 (enemies move at ≤ 8 u/s; 0.02 u is sub-pixel at PPU 32), UseUnreliableDeltas = true, tick-rate sends (ADR-0008 §8)buffer = global InterpolationBufferTickOffset (ADR-0009 §6; players win arbitration; enemies may get SmoothDampening by amendment)
PhaseNetworkVariable<byte> (EnemyPhase) — ADR-0015's one replicated enemy combat rowtelegraph VFX + pose flipbook are driven by the phase change on the client (TR-combat-007: never re-derived from local animation)
AnimFrameNetworkVariable<byte>, traits MinSecondsBetweenUpdates 0.05 (≤ 20 Hz) — written by the host's EnemyAnimator from the phase timer (frame = elapsed phase fraction × flipbook length)TR-tint-004; V2
ArchetypeNetworkVariable<ushort> (index), set before Spawn()EnemyView picks the flipbook set
ActorIdNetworkVariable<EnemyActorIdNet> (byte Socket, ushort Index — an unmanaged struct, our own type, fenced), set before Spawn()joins SwingLanded.Victim to a body for hit-flash/knockback FX
HP, meter, immunity, target, brain stateneverADR-0015; the spec's "no enemy HP bars at MVP"

6. Host budget and step placement

EnemyAI.Tick(ctx) (host step 3, ADR-0015): for each living enemy on a docked land — build Senses (players from ctx.Positions.All() mapped to keys via SessionState, phases from ICombatState.Enemy(actorId), cap count from AiWorld), EnemyBrain.Step, apply BrainActions (EnterTelegraph / LeashReset through IEnemyCombat; DesiredVelocity/Displace into EnemyMotor). Budget ≤ 0.3 ms per tick for 20 enemies (Editor profile), 0 B allocated per tick in steady state (Senses is a struct over pooled lists). EnemyAttackResolver (combat) runs from combat's own Telegraph → Active transition inside step 2 (CombatState.Tick's phase advance — TD C3, ADR-0015 amended) — the AI never calls it.

7. Fences (EditMode unless noted)

Fence Asserts
Ai_BoundaryIsTwoCallsRuntime.Enemy + Rules.Enemy invoke exactly IEnemyCombat.EnterTelegraph and IEnemyCombat.LeashReset on the combat surface (raw-text + reflection); no ICombatState mutation member is referenced
Ai_NoColliderQueriesno Physics2D, Collider2D, Rigidbody2D.Cast reference in Runtime.Enemy (raw-text) — movement only via KinematicMover2D
Ai_NoEnemySaveTableno ISaveable/IReconcilable implementation in Runtime.Enemy; no save TableId containing enem (reflection + the ADR-0004 table registry)
Ai_CompositionIsPureComposition.Choose lives in Driftlands.Rules (engine-free by ADR-0003's fence) and returns byte-identical output for equal (template, seed, points) across 100 runs and a differing one for seed + 1
Ai_TiebreakIsDeterministictwo enemies at the same point separate along TiebreakDirection(index); indices 0..15 yield 16 distinct directions; no Random reference in Rules.Enemy/Runtime.Enemy (raw-text)
Ai_DensityCapHoldswith two uninterruptible archetypes aggroed on one player, the second never enters Telegraph while the first's is active (the quick-spec's UNIT AC)
Ai_DeAggroResetsleash / off-land / no-progress each produce LeashReset exactly once and a Return to post; HP restoration is asserted on the combat side (ADR-0015's test)
Ai_NoClientAiEnemyMotor/EnemyAI/EnemySpawner are server-guarded (IsServer checks asserted by a PlayMode test on a client-mode fixture: no tick runs); EnemyView references no brain/motor type (reflection)
Ai_WireStructsUnmanagedEnemyActorIdNet (byte Socket, ushort Index) is unmanaged, no reference field (reflection) — a NetworkVariable<T> constraint that would fail at compile time, fenced so a later edit cannot add a managed field silently
Schema_EnemyDefEnemyDef/AttackDef allow-lists per §1; every TelegraphMs ≥ 400 in the shipped data (ADR-0006 row exercised)

Architecture Diagram

HOST                                                                          CLIENTS
LandActivated ─► EnemySpawner: Composition.Choose(seed) ─► QueueSpawn(Enemy ×n) ─► after drain: prefab + EnemyNet{ActorId, Archetype, Phase, AnimFrame} + Spawn(); CombatState.AddEnemy
tick step 3: EnemyAI.Tick ─► per enemy: Senses(HostKnown players, CombatPhase, cap count, LandContains) ─► EnemyBrain.Step (pure, EnemyDef params)
                              ├─ EnterTelegraph(attackId) ──► combat: Telegraph→Active→Recovery advanced in step 2; resolver fires at step 2's Telegraph→Active (ADR-0015 amended)   ══ Phase NV (+1 tick) ══► EnemyView: telegraph pose/rim
                              ├─ LeashReset() ────────────► combat: HP max, meter/immunity cleared
                              └─ DesiredVelocity / Displace(lunge) ──► EnemyMotor (+ Separation, deterministic tiebreak) ─► KinematicMover2D sweep ══ EnemyTransform (Server auth, 50 Hz) ══► interpolated body
combat F5 knockback ─► IDisplacementSink.Displace(Knockback) ─► same sweep                                                          ══ AnimFrame NV (20 Hz) ══► flipbook frame
death: Phase Dead → host-timed linger → Despawn + row removed · EvictionExpired: all enemies on socket Despawn (before LandNet), lingers dropped · LandTornDown: rows removed · nothing saved, ever

Key Interfaces

namespace Driftlands.Data
{
    public readonly record struct EnemyActorId(SocketId Socket, ushort Index);                       // ADR-0015 renamed from EnemyId(landSocket, index); Data.EnemyId stays the archetype key
    public sealed record AttackDef(AttackId Id, int TelegraphMs, int ActiveMs, int RecoveryMs, AttackShape Shape, int Damage, Knockback Knockback, float LungeDistance, bool Uninterruptible);
    public sealed record EnemyDef(EnemyId Id, ThreatTier Tier, float MoveSpeed, float PatrolSpeed, float PreferredRange, bool BackpedalsWhenClosed, bool Interruptible, IReadOnlyList<AttackDef> Attacks);   // enemies.json; HP/damage bands are combat.json's
    public sealed record AiConfig(float AggroRadius, float LeashRadius, float DeAggroSecs, float SeparationRadius, float SeparationStrength, float SeparationWhileAttacking, int MaxUninterruptibleTelegraphsPerTarget, GuardDensity GuardDensityByBand, float DeathLingerSecs, float NoProgressEpsilonUnits);   // enemy-ai.json
}

namespace Driftlands.Rules.Enemy                       // engine-free
{
    public enum BrainMode : byte { Dormant, Aggro, Return, Dead }
    public readonly record struct BrainState(BrainMode Mode, PlayerKey? Target, Vector2 GuardPost, int PatrolIndex, long NoProgressSinceTick, float LastDistToTarget, long AttackCooldownEndsTick, AttackId? PendingAttack);
    public readonly record struct Senses(Vector2 Self, EnemyPhase CombatPhase, bool Interruptible, bool WasHitSinceLastTick, IReadOnlyList<PlayerSense> Players, int UninterruptibleTelegraphsOnTarget, bool TargetOnLand);
    public readonly record struct BrainActions(Vector2 DesiredVelocity, AttackId? EnterTelegraph, bool LeashReset, DisplacementRequest? Displace);
    public static class EnemyBrain { public static (BrainState next, BrainActions actions) Step(BrainState s, Senses in, EnemyDef def, AiConfig cfg, long nowTick, float tickDt); }
    public readonly record struct EnemySpawn(ushort Index, SpawnPoint Point, EnemyId Archetype, byte FacingOctant, IReadOnlyList<Cell> PatrolPath);
    public static class Composition { public static IReadOnlyList<EnemySpawn> Choose(TemplateId template, ulong variationSeed, IReadOnlyList<SpawnPoint> points, GuardDensity density, IReadOnlyDictionary<Band, IReadOnlyList<EnemyId>> allowedByBand); }   // pure, seeded
    public static class Steering { public static Vector2 Separation(Vector2 self, IReadOnlyList<Vector2> neighbours, AiConfig cfg, ushort selfIndex); public static Vector2 TiebreakDirection(ushort index); }
}

namespace Driftlands.Rules.Combat { public partial interface IEnemyCombat { void EnterTelegraph(AttackId id); void LeashReset(); bool Interruptible { get; } float Meter { get; } EnemyPhase Phase { get; } } }   // exactly two calls (ADR-0015 amended)

namespace Driftlands.Runtime.Enemy
{
    public sealed class EnemySpawner : IEnemySpawner { /* host: LandActivated → Composition.Choose → QueueSpawn(Enemy) → prefab + EnemyNet + Spawn; EvictionExpired → Despawn all on socket (before the LandNet despawn, §2 TD C6); LandTornDown → rows removed */ }
    public sealed class EnemyAI { public void Tick(HostContext ctx); }                            // host step 3 (ADR-0015); builds Senses, steps every brain, applies actions
    public sealed class EnemyMotor : MonoBehaviour { /* host only; DesiredVelocity + Separation → KinematicMover2D.Drive; IDisplacementSink forwards to the mover */ }
    public sealed class EnemyAnimator { /* host: AnimFrame from phase fraction × flipbook length, ≤ 20 Hz */ }
    public sealed class EnemyNet : NetworkBehaviour {
        public NetworkVariable<byte> Phase = new(writePerm: NetworkVariableWritePermission.Server);
        public NetworkVariable<byte> AnimFrame = new(writePerm: Server);                          // SetUpdateTraits(Min 0.05 s) in Awake
        public NetworkVariable<ushort> Archetype = new(writePerm: Server);                        // set before Spawn()
        public NetworkVariable<EnemyActorIdNet> ActorId = new(writePerm: Server);                 // byte Socket, ushort Index — set before Spawn()
    }
    public sealed class EnemyTransform : NetworkTransform { /* AuthorityMode = Server; ADR-0009 settings; PositionThreshold 0.02 */ }
    public sealed class EnemyIndexTable { public ushort Of(EnemyId id); public EnemyId At(ushort i); public uint Hash { get; } }
}

namespace Driftlands.Presentation.Enemies { public sealed class EnemyView : MonoBehaviour { /* Archetype × Phase × AnimFrame flipbook; telegraph on Phase→Telegraph; Stagger White on SwingLanded{Victim == ActorId}; no AI */ } }

Alternatives Considered

Alternative B: enemies as rows in a per-land NetworkList with our own client interpolation

Alternative C: behaviour trees / utility AI

Alternative D: three archetype classes

Alternative E: grid A* / navmesh pathing

Alternative F: combat reads the AI's aggro state and heals in CombatState.Tick

Alternative G: reduced enemy transform cadence (e.g. 20 Hz)

Consequences

Positive

Negative

Risks

GDD Requirements Addressed

GDD / spec Requirement How This ADR Addresses It
enemy-ai Rule 1 (TR-ai-001)three named behaviours§3: one machine, three EnemyDef parameter sets
enemy-ai Rule 2 (TR-ai-001)flat host machine; clients render replicated transforms + telegraph events§3 + §5
enemy-ai Rule 3 (TR-ai-002)separation steering, movement-layer only§4 (deterministic tiebreak delivered for ADR-0009)
enemy-ai Rule 4 (TR-ai-002)aggro/leash/de-aggro heal; shore absolute§3 + LeashReset (boundary widened by user decision)
enemy-ai Rule 6 (TR-ai-003)cap = 1 uninterruptible telegraph per target§3 AI-side count
enemy-ai Rules 7–9 (TR-ai-004/005, TR-ldu-005)spawn at activation; pure composition; no respawn; ephemeral; density monotonic§2; fences Ai_CompositionIsPure, Ai_NoEnemySaveTable; ADR-0006 rows
enemy-ai Rule 11party scaling lever pre-armed, scalingContext emptyEnemyScalingContext.Empty passed through; guardDensityByBand is the lever
combat Rule 11, AC-20/25telegraph ≥ 400 ms; stagger/knockback; interruptibility; remote read§3 timing on ticks; knockback via ADR-0009; interruptibility read from combat; AC-25 = V4
combat TR-combat-007telegraph is replicated state§5 Phase
art-bible TR-tint-004flipbook frame as one synced int§5 AnimFrame
harvesting Rule 2enemies win aim disambiguationnothing here (named so no one looks)

Performance Implications

Migration Plan

Validation Criteria

Related Decisions

Editorial amendments — 2026-09-11 (TD-MANIFEST C4)