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/, recordproduction/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 |
|---|---|
| Engine | Unity 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 |
| Domain | Core (AI) + Networking (replication of enemy bodies) |
| Knowledge Risk | LOW 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 Consulted | docs/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 Used | NetworkObject.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-0015 | The 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-0009 | The 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-0011 | Honoured: Runtime.Enemy never queries a collider; movement goes through the sweep only (fence Ai_NoColliderQueries). | |
| ADR-0005 | IEnemySpawner (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-0006 | enemies.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.md | Rule 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.md | Required-ADR row 12 ✅; §2 EnemyAI row loses its ⚠️; §4 IEnemySpawner signature unchanged. |
ADR Dependencies
| Field | Value |
|---|---|
| Depends On | ADR-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) |
| Enables | the 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) |
| Blocks | EnemyAI epic; combat enemy-attack stories; IEnemySpawner real implementation in land lifecycle |
| Ordering Note | Last 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
- ADR-0015:
EnemyAI.Tick(ctx)is host step 3, afterCombatState.Tick, before the drain; combat owns HP/meter/phase transitions (EnterTelegraph→Telegraph → Active → Recoveryon combat's timers;Stagger/Deadfrom hits);EnemyAttackResolverruns onTelegraph → Active; enemy HP and meters never replicate; enemies die with their land. - Enemy-ai Rules 2–4, 6–9, 11: flat machine, host-only, no client AI/prediction; steering never delays an attack; aggro on proximity or damage; leash to guard post / shore threshold / unreachable timer with heal-to-full; density cap 1 uninterruptible telegraph per target; composition pure from
(template, variationSeed); no respawn; ephemeral;scalingContextempty. - ADR-0009: enemies share
KinematicMover2D+ sweep;Enemy↔Enemycollision off; knockback =IDisplacementSink; the coincident-start tiebreak is this ADR's; interpolation buffer is global (players win). - ADR-0011: no collider queries for pathing (registered).
- ADR-0008: state = server variables on the owning object; edges = outcomes;
AnimFrametraits 0.05 s; one-tick state lag; no relevancy. - Data-driven: every number is
enemies.json/enemy-ai.json(ADR-0006); combat's HP/damage bands live incombat.json.
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
EnemyActorId(SocketId Socket, ushort Index)— the per-actor key (ADR-0015 renamed):Indexis the enemy's position in the docking's composition list (0..n−1), so the same docking key yields the same ids on every machine and every reload (Rule 8).Data.EnemyIdstays the archetype key. On the wireActorIdisSocketId.Value (byte) + Index (ushort).EnemyIndexTable(Runtime): ordinal-sortedIGameData.Enemieskeys →ushort Archetype; appended todataHashafter structures (ADR-0011's fixed order).EnemyDef(enemies.json, ADR-0006 — the marker gets its fields):{ Id, Tier (small/large silhouette family — art §5.2), MoveSpeed, PatrolSpeed, PreferredRange (0 for melee), BackpedalsWhenClosed (Spacer), Interruptible (Rusher only at MVP), Attacks[] }withAttackDef { AttackId, TelegraphMs ≥ 400, ActiveMs, RecoveryMs, Shape (arc | thrown-point | slam-circle: radius/arc/range), Damage, Knockback{Distance, Secs}, LungeDistance (Rusher), Uninterruptible }— HP and damage bands are combat's (combat.json) and validated there;EnemyDefvalues must sit inside them (ADR-0006 cross-table row, warning).AiConfig(enemy-ai.json):aggroRadius 6 (4–10),leashRadius 14 (10–20),deAggroSecs 6 (3–12),separationRadius 1.2 (0.8–2.0),separationStrength,maxUninterruptibleTelegraphsPerTarget 1 (fixed),guardDensityByBand {shore 0–1, mid 1–2, deep 2–3}(monotonic — load-checked),deathLingerSecs,noProgressEpsilonUnits(progress threshold for the unreachable timer).
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)
- Where it runs:
EnemySpawnersubscribes toILandLifecycle.LandActivated(raised in tick step 1, ADR-0005) and enqueues spawns into the tick'sHostContextdeferred-effect buffer, flushed at phase 4 after the envelope loop — never insideExecute, never re-entrant. ADR-0002 §5 step 4 is amended the way ADR-0009 amended it forBroadcast:QueueSpawn/QueueDespawnare callable from tick steps 1–3, flush order tick-step requests first, then command requests (the registry'sspawn_or_send_inside_executeban is scoped toExecuteand is consistent).SpawnRequestgainsSpawnKind.Enemy(ADR-0010's shape extended).CombatState.AddEnemyruns in that same phase-4 flush — a fourth write site ADR-0015's single-writer sentence must name explicitly (TD C2): row creation/destruction only, never a field write on a live row. - Per-point composition (Rule 8/9): for each
SpawnPointthe seeded stream picksk ∈ guardDensityByBand[point.Band]enemies, then an archetype per enemy from the template's authored allow-list per band (TemplateDef.GuardArchetypesByBand, ADR-0005/0006 data), facing and a 0–2-point patrol offset; node counts are untouched. The load check warns when a template's deep spawn-point count does not exceed its shore count (Rule 9). Total enemies per land ≤Σ max(density) × points— ≈ 10 at MVP (V1). - No respawn (Rule 7): the spawner runs once per activation; death removes the row and the object for the docking's remainder. Ephemeral (Rule 7): no
ISaveableanywhere inRuntime.Enemy(fence); a reload re-derives the composition and every enemy returns — accepted by the spec. - Teardown (TD C6): ADR-0005 raises
LandTornDownafterLandNetdespawns, so the enemy sweep hangs offEvictionExpired(socket)(raised before theLandNetdespawn):EnemySpawnerdespawns every enemy on the socket (Despawn(destroy: true)) there — hence beforeLandNet(V3) — and drops any pending death-linger timer;LandTornDownthen removes the rows (CombatState.RemoveEnemiesOn(socket), ADR-0015 "enemies die with their land"). ADeadenemy mid-linger is despawned by the sweep; the linger is a presentation courtesy, never a lifetime guarantee. - Death: combat sets
Phase = Dead(ADR-0015); the AI machine entersDead, stops steering; afterdeathLingerSecs(data — long enough for the death flipbook) the spawner callsDespawn(destroy: true)and combat removes the row. The linger is host-timed by holding the object alive — NGO'sDespawnsends the destroy immediately (srcNetworkObject.cs~2112–2126;DeferredDespawnTickis a separate opt-in feature this ADR does not use), and clients destroy on receipt. The kill is observed by clients asSwingLanded{Lethal}(edge) andPhase = Dead(state, one tick later — ADR-0008 §2).
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) }
- Modes (Rule 2's flat machine):
Dormant(at post; patrol between post andPatrolPathatPatrolSpeed) → aggro on a living, non-downed player withinaggroRadiuson this land, orWasHitSinceLastTick(always) →Aggro(target = nearest such player at aggro time; re-targets only when the target dies/leaves the land) → per tick: approach (DesiredVelocitytoward the target; Spacer holdsPreferredRange, backpedals when closed on; Bulwark advances at its slow speed) ⇄ attack (when in the chosen attack's range andCombatPhase == Idleand cooldown elapsed and the density cap allows:EnterTelegraph(attackId)— combat then owns Telegraph/Active/Recovery on its timers, advanced in step 2 (CombatState.Tick), whoseTelegraph → Activetransition firesEnemyAttackResolver(ADR-0015's §1/§4 wording disagreed on step 2 vs 3; ruled here: step 2, so the brain'sSenses.CombatPhaseread in step 3 is the same tick's post-advance phase — never stale — andPendingEnemyDamagefrom step 2 is consumed in phase 1b the same tick); the brain readsCombatPhaseand emits zero velocity during Telegraph/Active/Recovery except a Rusher's authoredLungeDistanceas aDisplace(Dash-kind)onTelegraph → Active, integrated by the mover) → de-aggro whendist(target, GuardPost) > leashRadiusor the target is not on this land (the shore threshold is absolute —Senses.LandContains(targetPos)) or no progress toward the target fordeAggroSecs(LastDistToTargetnot reduced bynoProgressEpsilonUnits— the cliff case) →Return(velocity towardGuardPostatPatrolSpeed;LeashReset = trueonce, on entry — combat restores HP, clears stagger/immunity) →Dormantat the post.Deadis terminal. Re-aggro on damage overrides every mode (TD C4):WasHitSinceLastTickre-entersAggrofrom any non-Deadmode — includingReturn— targeting the attacker, and it is evaluated before the de-aggro tests, so an enemy shot in the back on its way home turns around and a damaged enemy can neverLeashResetin the tick after it was hit. Ordering ofLeashResetagainst hits, stated: it is applied in step 3, before the same tick's drain; a strike resolving in that drain damages a full-HP enemy (correct — the leash held when it fired); a hit in the previous tick's drain setsWasHitSinceLastTick, which wins in step 3. - Interruptibility is combat's (
IEnemyCombat.Interruptible; a hit during a Rusher's Telegraph sends combat's phase to Stagger — the brain simply seesCombatPhasechange and resumes approach). The brain never reads or writes HP or the meter. - The density cap (Rule 6, TR-ai-003) is AI-side:
AiWorld.UninterruptibleTelegraphsOn(target)counts enemies whoseBrainState.Target == target, whose pending/current attack isUninterruptible, and whoseCombatPhase == Telegraph; a Spacer/Bulwark whose count ≥maxUninterruptibleTelegraphsPerTargetdoes not enter Telegraph — it holdsPreferredRange/keeps approaching and retries next tick. Rushers are exempt. No combat call is involved. The cap counts AI intent (BrainState.Target), not resolver coverage (TD C5):EnemyAttackResolverhits every player in the shape, so a player standing inside a second enemy's shape can be hit by two uninterruptible attacks while the cap reads 1. Accepted at MVP — the spec's rule is about lockout targeting; a coverage-based cap would need the resolver to run speculatively. Revisit if the playtest reports CC-lock. - Telegraph timing:
AttackDef.TelegraphMs ≥ 400(load-rejected otherwise, combat AC-20) = ≥ 20 ticks at 50 Hz; combat's phase timer is tick-based (ADR-0015), so the floor is exact host-side. The remote read (AC-25) is the playtest's measurement (V4). - Aim disambiguation (Rule 5) and knockback/stagger are combat's/harvesting's — nothing here.
- Why one machine, not three classes: the three behaviours differ in
PreferredRange,BackpedalsWhenClosed, speeds,Interruptible,Uninterruptibleattacks andLungeDistance— every one aEnemyDeffield. A fourth archetype that needs a new mode is the Tier 1 conversation the spec names; a new parameter is data.
4. Movement — steering through the shared mover, no pathfinding, no colliders
EnemyMotor(Runtime.Enemy, host, one per enemy;enabledonly on the server): each rendered frame it takes the last tick'sDesiredVelocity, adds separation (Rules.Enemy.Steering.Separation(self, neighbours within cfg.SeparationRadius, cfg) → Vector2— inverse-distance push from every other living enemy on the land;KinematicMover2D.Drive(velocity, LocomotionMode.Normal, dt)integrates it through ADR-0009's sweep (terrain blockers stop/slide it; noEnemy↔Enemycollision — steering is the separation). Knockback arrives asIDisplacementSink.Displace(Knockback)from combat's F5 (ADR-0015/0009) and slides like ordinary movement ("no terrain kills").- Coincident-start tiebreak (ADR-0009 TD C6, delivered): when two enemies are within
coincidentEpsilon(0.01 u) the separation direction isSteering.TiebreakDirection(EnemyActorId.Index)= the unit vector at angleIndex × goldenAngle (137.5°)— deterministic, distinct for any two indices, never random (the prototype'sRandom.insideUnitCirclefallback is the anti-pattern). - Steering never delays an attack (Rule 3): during Telegraph/Active/Recovery the brain's
DesiredVelocityis zero and separation is scaled bycfg.SeparationWhileAttacking(0 at MVP — an attacking enemy holds its ground; neighbours move around it). - No pathfinding, no collider queries (user decision; ADR-0011): steering is straight-line seek + separation; a cliff between enemy and target is the
deAggroSecsno-progress case (V5).Runtime.Enemycontains noPhysics2D/Collider2Dreference (fence) — onlyKinematicMover2Dcasts, and only inside the sweep. - Leash geometry:
GuardPost= the spawn cell's centre; the shore threshold isLandInstance's cell bounds (Senses.LandContainsfromILandBounds, aRules.Landquery over the instance — pure); an enemy never leaves its land's bounds (the sweep also stops at the water edge, but the rule is the bound, not the collider).
5. Replication — one NetworkObject per enemy
| State | Representation | Notes |
|---|---|---|
| position | EnemyTransform : 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) |
Phase | NetworkVariable<byte> (EnemyPhase) — ADR-0015's one replicated enemy combat row | telegraph VFX + pose flipbook are driven by the phase change on the client (TR-combat-007: never re-derived from local animation) |
AnimFrame | NetworkVariable<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 |
Archetype | NetworkVariable<ushort> (index), set before Spawn() | EnemyView picks the flipbook set |
ActorId | NetworkVariable<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 state | never | ADR-0015; the spec's "no enemy HP bars at MVP" |
EnemyView(Presentation, every machine): flipbook byArchetype × Phase × AnimFrame; telegraph pose/rim onPhase → Telegraph; Stagger White onSwingLanded{Victim == ActorId, StaggerFired}; death flipbook onPhase → Dead; facing from interpolated velocity (ADR-0009 §8's rule). No client AI, no prediction, no local timers that gate anything (a client never decides a phase).- Late join / relevancy: enemies are dynamically spawned objects — a joiner receives them with their pre-spawn variables (source-confirmed path, ADR-0010 V1); no relevancy (ADR-0008); at ≤ ~10 enemies per docked land the whole set is ≤ 10 × 28 B × 50 Hz ≈ 14 KB/s down, inside ADR-0008's envelope.
- Cadence: full tick rate. A reduced enemy cadence is deliberately not built (NGO offers no per-instance tick rate; ADR-0009's buffer is global); if bandwidth ever measures,
PositionThresholdis the knob.
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_BoundaryIsTwoCalls | Runtime.Enemy + Rules.Enemy invoke exactly IEnemyCombat.EnterTelegraph and IEnemyCombat.LeashReset on the combat surface (raw-text + reflection); no ICombatState mutation member is referenced |
Ai_NoColliderQueries | no Physics2D, Collider2D, Rigidbody2D.Cast reference in Runtime.Enemy (raw-text) — movement only via KinematicMover2D |
Ai_NoEnemySaveTable | no ISaveable/IReconcilable implementation in Runtime.Enemy; no save TableId containing enem (reflection + the ADR-0004 table registry) |
Ai_CompositionIsPure | Composition.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_TiebreakIsDeterministic | two 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_DensityCapHolds | with 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_DeAggroResets | leash / 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_NoClientAi | EnemyMotor/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_WireStructsUnmanaged | EnemyActorIdNet (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_EnemyDef | EnemyDef/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
- Pros: no
NetworkObjectper enemy; one delta stream per land. - Cons: re-implements
NetworkTransform's buffered interpolation, teleport flag and unreliable deltas; a second interpolation model to tune at the playtest beside the players';SwingLanded.Victim→ body lookup becomes a list scan; ADR-0009's shared mover expects a transform per body on the host anyway. - Rejection Reason: ≤ ~10 bodies per land is well inside what per-object replication costs; one interpolation model for every moving thing is the playtest's simplification.
Alternative C: behaviour trees / utility AI
- Rejection Reason: the quick-spec's explicit out-of-scope ruling (Rule 2); three archetypes are three parameter sets of one flat machine.
Alternative D: three archetype classes
- Cons: three state machines to test, three places for the density cap and leash rules to diverge.
- Rejection Reason: every behavioural difference the spec names is a number or a flag in
EnemyDef; a mode that is not is the Tier 1 conversation.
Alternative E: grid A* / navmesh pathing
- Rejection Reason: user decision 2026-09-11 — walls do not exist on docked lands and terrain blockers are sparse; the spec's
deAggroSecs"unreachable" rule already handles the cliff case; a walkability export per template is Tier 1 work for no MVP behaviour.
Alternative F: combat reads the AI's aggro state and heals in CombatState.Tick
- Rejection Reason: user decision 2026-09-11 — it inverts the registered dependency; a second named call keeps the boundary explicit and fenced.
Alternative G: reduced enemy transform cadence (e.g. 20 Hz)
- Rejection Reason: NGO offers no per-instance tick rate; a home-rolled throttle would fight
NetworkTransform's interpolator; bandwidth at MVP scale does not justify it.PositionThresholdis the knob if it ever measures.
Consequences
Positive
- One pure step function covers three archetypes and is exhaustively testable in EditMode with no engine; the density cap, leash and de-aggro rules exist in exactly one place.
- The AI→combat boundary is two named calls, fenced; enemies never touch HP, meters or positions of anyone else.
- Composition is byte-identical everywhere by construction, so guard variation (harvesting's live-variance mechanism) is testable as a pure function.
- Bodies reuse ADR-0008/0009's replication and mover wholesale — nothing enemy-specific on the wire beyond four small variables.
Negative
- The density cap is an intent cap: a player inside a second enemy's attack shape can take two uninterruptible hits (stated, accepted at MVP).
- Straight-line steering means an enemy behind a cliff gives up after
deAggroSecsrather than walking around — accepted for sparse MVP lands. - The interpolation buffer is shared with players (ADR-0009); enemies may look slightly smoother or laggier than ideal without a per-instance knob.
- A reload mid-docking resurrects killed enemies (spec Rule 7's accepted consequence) — stated, not fixed.
AnimFramecosts ≈ 20 Hz × ~5 B per moving enemy; trivial, but it is traffic a derive-locally design would not have.
Risks
- Steering oscillation between seek and separation at
PreferredRange(Spacer jitter). Mitigation:cfg.SeparationStrengthand a small dead-band onPreferredRangeinEnemyBrain; PlayMode fixture with two Spacers on one target. - Spawn burst on activation (V1): ≤ ~10 spawn messages in one tick. Mitigation: measured; if it ever matters, the spawner can spread spawns over consecutive ticks without any wire change.
- Telegraph read at real RTT (AC-25): the playtest measures; the fix is dash tuning/telegraph length in data (combat's CD note), never lag compensation.
- A fourth archetype needing a new mode: the spec's Tier 1 conversation;
EnemyBraingrows a mode by ADR amendment, not by a class.
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 11 | party scaling lever pre-armed, scalingContext empty | EnemyScalingContext.Empty passed through; guardDensityByBand is the lever |
| combat Rule 11, AC-20/25 | telegraph ≥ 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-007 | telegraph is replicated state | §5 Phase |
| art-bible TR-tint-004 | flipbook frame as one synced int | §5 AnimFrame |
| harvesting Rule 2 | enemies win aim disambiguation | nothing here (named so no one looks) |
Performance Implications
- CPU (host): ≤ 0.3 ms/tick for 20 enemies (brain step + senses + steering), 0 B/tick steady state; one sweep per enemy per rendered frame (ADR-0009's cost).
- Memory: one
NetworkObject+EnemyNet+ mover per enemy (≤ ~10 per docked land). - Network: ≈ 14 KB/s down for 10 moving enemies at 50 Hz + ≤ 1 KB/s of
Phase/AnimFrame; inside ADR-0008 §8's envelope. - Load time:
EnemyIndexTableat data load; composition at activation (µs).
Migration Plan
EnemyDef/AttackDef/AiConfigfields +enemies.json+enemy-ai.json+ validation rows (AC-20 floor, density monotonic, deep > shore warning, bands cross-check) +RequiredFiles(+ bothTestJsonfixtures);EnemyIndexTableappended todataHash— EnemyAI Data story.Rules.Enemy:Composition.Choose,EnemyBrain.Step,Steering— EditMode, mutation-checked against the quick-spec's UNIT ACs (cap, de-aggro, purity, tiebreak).- ADR-0015 amendments in code:
EnemyActorIdrename,IEnemyCombat.LeashReset,ICombatState.AddEnemy/RemoveEnemiesOn,Targetout ofEnemyActor— combat epic story. - Runtime:
EnemySpawneron the lifecycle seam (withSpawnRequest.Enemy, teardown order V3),EnemyAI.Tickwired as step 3,EnemyMotoroverKinematicMover2D,EnemyNet/EnemyTransformprefab,EnemyAnimator; fences. - Presentation:
EnemyView(flipbooks, telegraph pose/rim, Stagger White fromSwingLanded) — feedback presenters epic. - Playtest amendment: cadence/interpolation and telegraph read from the CSV + warp counter.
Validation Criteria
- EditMode: the §7 fences with companion negatives; the quick-spec's UNIT ACs as tests over
EnemyBrain/Composition; a scripted two-Spacer fixture shows no oscillation beyond a dead-band;TiebreakDirectiondistinctness. - PlayMode (host): activation spawns exactly
Composition.Choose's count with matchingActorIds; teardown despawns all beforeLandNet; a client-mode fixture runs no AI tick; V1/V2/V5. - Documented playtest / two standalone builds: the quick-spec's INTEGRATION ACs (full docking cycle with a different composition on the next docking; five Rushers never blob); AC-25 telegraph read at real RTT; warp counter on enemies.
Related Decisions
- ADR-0015 (actor table, phases, step order, boundary), ADR-0009 (mover, displacement, tiebreak, buffer arbitration), ADR-0008 (variables, cadence,
dataHash, lag), ADR-0005 (spawn seam, lifecycle), ADR-0011 (collider rule), ADR-0006 (data), ADR-0003 (Rules.Enemy). design/quick-specs/enemy-ai-2026-08-27.md,combat-system.mdRule 11,land-dock-undock-2026-08-27.mdRule 5,art-bible.md§5,accessibility-requirements.md;architecture.mdrow 12;prototypes/combat-netcode-feel-concept/Scripts/DummyEnemy.cs(the prototype's chase/stack/tiebreak history — reference only).
Editorial amendments — 2026-09-11 (TD-MANIFEST C4)
- The
EnemySpawnerKey-Interfaces comment said the enemy sweep hangs offLandTornDown, contradicting §2's TD C6 ruling (EvictionExpired, which fires before theLandNetdespawn;LandTornDownfires after it and only removes the rows). Comment corrected in place. No decision changed.