Driftlands — Master Architecture
Document Status
- Version: 1.0 (all sections approved 2026-08-27 via /create-architecture)
- Last Updated: 2026-08-27
- Engine: Unity 6.3 LTS (6000.3) · URP 2D Renderer (Render Graph) · NGO 2.x · Input System · Addressables · C#
- GDDs Covered: tide-scheduler, combat-system, harvesting-system, crafting-system, base-building (full); netcode-session, camera, save-persistence, player-movement, inventory-hauling, land-dock-undock, enemy-ai, ui-hud (quick-specs); tide-tint (art bible §4.3/§8.B.3, index rows 14/15)
- ADRs Referenced: ADR-0001 … ADR-0007, ADR-0015 (Accepted 2026-08-28), ADR-0013, ADR-0014 (Accepted 2026-09-01 via
architecture-review-2026-09-01.md) — 5 remaining: rows 8–12 (see Required ADRs) - Technical Director Sign-Off: 2026-08-27 — APPROVED WITH CONDITIONS (ADRs 1–7 Accepted before the first implementation story; QQ-12 shapes in ADR 3; QQ-01/02/03 answered from the 6000.3 / NGO 2.x docs before ADRs 2/8/9 leave Proposed)
- Lead Programmer Feasibility: 2026-08-27 — CONCERNS ACCEPTED (five items, none infeasible; document revised same day: §3.1 drain ordering marked provisional, harvest-node replication re-hedged → ADR 10, QQ-11/12/13 added, ADR 2/3/4 scopes extended)
Engine Knowledge Gap Summary
Engine: Unity 6.3 LTS · LLM training covers: ~Unity 6.0 · Post-cutoff: 6.1, 6.2, 6.3.
| Risk | Domain | What changed / why it matters | Systems touched |
|---|---|---|---|
| HIGH | URP Render Graph | Compatibility Mode removed in 6.3; any custom pass must be Render-Graph-native (AddRenderPasses). Mitigated for MVP: the tide tint is a Shader Graph sprite shader reading a global property, not a custom pass (art bible §8.B.3). | Tide-Tint (TR-tint-001/003), BB ghost/grid overlay (TR-bb-009) |
| HIGH | NGO 2.x | 1.x deprecated; NetworkTransform.OnUpdate replaces the Update override; Multiplayer Play Mode 2.0.1 local instances only. The project's own modules/networking.md still shows 1.x-style [ServerRpc] and legacy Input examples — the 2.x RPC attribute surface and NetworkTransform snap thresholds are NEEDS VERIFICATION against the 6000.3 / NGO 2.x docs. | Every host-rail system: TR-net-*, TR-combat-002/003/007, TR-harv-001/002, TR-craft-003/008, TR-bb-002/007, TR-ai-001, TR-move-002/004/005 |
| MEDIUM | UI Toolkit | USS strict parsing blocks import on invalid selectors | TR-ui-* |
| MEDIUM | Addressables 2.x | async / Awaitable load patterns; release discipline | TR-ldu-001/007 |
| MEDIUM | 2D content pipeline | Tile Set assets (6.1), Aseprite importer auto-Tilemap | land-template authoring, TR-ldu-002 |
| MEDIUM | Awaitable / async scripting | directly relevant to the no-yield host-commit rule (TR-net-007) | Foundation |
| LOW | Input System, Physics2D (Box2D), Unity Test Framework, [SerializeField] fields-only | stable; note modules/physics.md documents 3D PhysX, not the 2D API this project uses | TR-move-001/003, TR-bb-007 |
Rule: every decision in a HIGH domain in this document is marked ⚠️ NEEDS VERIFICATION and lands as an ADR input, not a settled fact.
Technical Requirements Baseline
Extracted from 13 design docs | 94 rows below (the original "102" was a miscount). Registered 2026-08-28 in docs/architecture/tr-registry.yaml (v2, 111 IDs: these 94 + 17 surfaced by the review — see traceability-index.md). The registry, not this table, is canonical.
| Req ID | System | Requirement | Domain |
|---|---|---|---|
| TR-tide-001 | Tide | DockedLands(seed, t, config) pure function in an engine-free C# assembly (no UnityEngine refs) | Core |
| TR-tide-002 | Tide | long UTC seconds, StableHash64 splitmix, explicit FloorDiv/FloorMod; System.Random/GetHashCode/Mathf/float banned in schedule math | Determinism |
| TR-tide-003 | Tide | Host-authoritative t replicated on join/change; client extrapolation presentation-only | Networking |
| TR-tide-004 | Tide | Monotonic clock guard lastKnownGoodT, persisted | Save/Load |
| TR-tide-005 | Tide | NextBoundary schedule-one-wake-up transition dispatch, no polling, ±5 s | Timing |
| TR-tide-006 | Tide | Dock/undock edge events + PreloadLeadTime notice subscribable by LDU | Events |
| TR-tide-007 | Tide | Derived-only API: (docked, progress) pair, cycleIndex, epochIndex, variationSeed, templateForDocking; raw t/φ/W never exposed to UI/render | API |
| TR-tide-008 | Tide | amberIntensity/violetIntensity floats; violet held 1.0 through EvictionGrace | Rendering |
| TR-tide-009 | Tide | TideConfig data-driven, load-validated (EC 15–18), versioned; epoch boundary on N change | Config |
| TR-tide-010 | Tide | ≤ 50 µs/call avg, 0 bytes alloc/call (hard gates per TD ruling), ≤ 0.1 ms/frame (advisory) | Performance |
| TR-net-001 | Netcode | NGO 2.x host-and-play; no dedicated server, no host migration, no rollback | Networking |
| TR-net-002 | Netcode | Two authority rails: owner (movement, dash, CARRY, predicted FX) / host (damage, HP, storage, commits, registries, marks) | Networking |
| TR-net-003 | Netcode | Late-join full snapshot: seed+config+t, structure registry, SharedStorage, Known* sets, full InstanceMetadata, player states | Networking |
| TR-net-004 | Netcode | Disconnect despawns the body; HP/Downed/bleed-out timer retained session-scoped; rejoin = late join | State |
| TR-net-005 | Netcode | Steam transport (package TBD) — ADR after playtest | Transport |
| TR-net-006 | Netcode | All host-rail commit RPCs share one ordered arrival channel (or explicit sequence numbers); tiebreak lowest OwnerClientId | Networking |
| TR-net-007 | Netcode | Every host-rail commit is a single non-yielding synchronous critical section on the host main thread (TD-C1) | Concurrency |
| TR-net-008 | Netcode | Execution-phase ordering of host commits vs. owner movement writes pinned (TD-C2) | Timing |
| TR-cam-001 | Camera | Orthographic, 64 px/u at 1080p, integer multiples elsewhere; PPU 32, point filter | Rendering |
| TR-cam-002 | Camera | Smoothed follow + aim bias; never detaches; hard cut on deposit/respawn; no shake | Core |
| TR-save-001 | Save | One host-owned file; write-temp-then-swap; rolling backups | Save/Load |
| TR-save-002 | Save | Persisted tables per Rule 2 incl. both host-monotonic instanceId counters | Save/Load |
| TR-save-003 | Save | Per-confirmed-action write-through durability (saveDebounceMs 0) | Save/Load |
| TR-save-004 | Save | saveFormatVersion/configVersion gate — refuse unknown, never migrate silently | Save/Load |
| TR-save-005 | Save | Fixed reconcile pipeline invoking each owner's predicate; two-class rule (only HarvestDiff droppable) | Save/Load |
| TR-save-006 | Save | Load: full HP, no Downed, position per tide EC1; no merge/import path (schema-negative) | Save/Load |
| TR-move-001 | Movement | Input System package; one action map for KB/M + gamepad; aim vector decoupled, persists on stick release | Input |
| TR-move-002 | Movement | Owner-authoritative client-predicted locomotion, 6 u/s, instant accel; no host correction at MVP | Networking |
| TR-move-003 | Movement | Collision blocks never damages: terrain, structures, bounds; no player/enemy body collision | Physics 2D |
| TR-move-004 | Movement | Remotes interpolated never extrapolated (NetworkTransform.OnUpdate path) | Networking |
| TR-move-005 | Movement | Closed teleport-snap list: tide deposit, respawn, BB EC21 correction (observer asymmetry → ADR) | Networking |
| TR-move-006 | Movement | Schema-negative: no MoveSpeedModifier, stamina, or encumbrance | Core |
| TR-inv-001 | Inventory | ItemStack = (itemId, count, instanceTag?); CanAdd/Add/Remove/Count; per-item maxStack; tagged stacks never merge | Data |
| TR-inv-002 | Inventory | CarryInventory owner-auth predicted; SharedStorage host-auth, N instances sited by BB | Networking |
| TR-inv-003 | Inventory | Transfers atomic host transactions in arrival order; duplication/loss invariant | Concurrency |
| TR-inv-004 | Inventory | No world-item entities; 2 equipment slots outside the bag | Scope |
| TR-inv-005 | Inventory | Stack-list serialization for Save (CARRY per player, STORAGE per container id) | Save/Load |
| TR-combat-001 | Combat | Player state machine (Idle/Move, Dashing, Strike W/A/R, Downed, Dead) + non-blocking Hurt overlay | Core |
| TR-combat-002 | Combat | Unified strike intent (swingId, aimDirection, targetKind, nodeId?) client→host at windup start | Networking |
| TR-combat-003 | Combat | Host resolves at fixed resolution instant vs. current positions; fixed tolerances; no history | Networking |
| TR-combat-004 | Combat | Feedback authority split: predicted (swing anim, whiff) vs host-confirmed (impact, stagger, death) | Presentation |
| TR-combat-005 | Combat | F1–F5 deterministic, AwayFromZero rounding, pure/DI-testable in EditMode | Core / Testing |
| TR-combat-006 | Combat | Enemy interface: EnterTelegraph(attackId); telegraph ≥ 400 ms; stagger meter; max-contribution knockback; interrupt flag | API |
| TR-combat-007 | Combat | Telegraph phase is replicated state (NetworkVariable), never local Animator-derived | Networking |
| TR-combat-008 | Combat | Dash i-frames owner-auth; host wins if dash not yet received (accepted) | Networking |
| TR-combat-009 | Combat | Downed/revive/bleed-out/Dead → home respawn; home heal + stabilize; keep-everything | Core |
| TR-combat-010 | Combat | Tide soft-close evaluates before combat resolution on the same host tick | Timing |
| TR-combat-011 | Combat | SwingLanded/DamageAbsorbed durability events, host-confirmed only | Events |
| TR-combat-012 | Combat | Load validation: telegraph ≥ 400 ms, swingWindup+swingActive ≥ 0.18, DR < 1.0, HP bands > 0; unarmed 14 | Config |
| TR-combat-013 | Combat | HUD data: own/party HP, downed indicator; damage numbers world-space | UI |
| TR-ldu-001 | LDU | Tide-edge subscription; Addressables async preload at NextBoundary − PreloadLeadTime; activate at φ; despawn+unload at φ+W+EvictionGrace | Assets |
| TR-ldu-002 | LDU | layout(template, variationSeed) pure: nodes, spawn points, scatter — byte-identical everywhere | Determinism |
| TR-ldu-003 | LDU | KnownLandTemplates host-auth append-only, seeded at world creation, permanent-class persisted | Data / Save |
| TR-ldu-004 | LDU | Sole-source load warning; depositPoint + socketNoBuildRadius authored per socket | Config |
| TR-ldu-005 | LDU | Enemy spawn contract (template, variationSeed, spawnPoints[], scalingContext) | API |
| TR-ldu-006 | LDU | traversalProfile per template; stripTimeEstimate CI readout (no assert) | Testing |
| TR-ldu-007 | LDU | Memory: home + 1 land + 1 preloading ≤ 2 GB; one atlas per template; per-template MB budget (owner TBD) | Performance |
| TR-harv-001 | Harvesting | Node = host-auth swingsRemaining int, Depleted computed; replicated to clients (representation → ADR 10; NetworkList<NodeState> candidate) | Data / Networking |
| TR-harv-002 | Harvesting | Three-gate resolution at completion tick (Docked, swingsRemaining, CanAdd) + decrement + Add + diff write in one host critical section | Concurrency |
| TR-harv-003 | Harvesting | HarvestDiff {(epoch, socket, cycle, nodeId), swingsRemaining} sparse, synchronous write | Save/Load |
| TR-harv-004 | Harvesting | Referent chosen at windup start (client, host-validated); harvest phase constants independent of combat's | Core |
| TR-harv-005 | Harvesting | Integer deterministic yield F1; teardown sequenced after last-tick resolution | Core / Timing |
| TR-harv-006 | Harvesting | Load validation: monotonic bands, modifier ≤ 2.0, itemId registered, no home-island nodes | Config |
| TR-harv-007 | Harvesting | Host proximity sanity check on intents; crack stages + yield flyouts host-confirmed | Networking / Presentation |
| TR-craft-001 | Crafting | RecipeDef schema + 11-row load validation table | Data / Config |
| TR-craft-002 | Crafting | KnownRecipes host-auth group set; union reconcile | Data / Save |
| TR-craft-003 | Crafting | craftLegality five gates; provisional at start, authoritative at commit; consume(CARRY ∪ STORAGE-in-range) + produce + instance + mark in one host critical section | Concurrency |
| TR-craft-004 | Crafting | InstanceMetadata side table keyed by host-monotonic instanceId; permanent; orphan/mystery-gear reconcile | Data / Save |
| TR-craft-005 | Crafting | Gear hook values per tier + per-item override; ceilings validated at Crafting's load | Data |
| TR-craft-006 | Crafting | Durability drain from combat/harvest events; instant repair at stationPresentAtLeast | Core |
| TR-craft-007 | Crafting | Era gate reads KnownLandTemplates; material→source-template mapping in item data (owner unnamed) | Data |
| TR-craft-008 | Crafting | 2 s craft beat client-predicted stance; commit host-side; no occupancy lock | Networking |
| TR-craft-009 | Crafting | Schema-negative: no craft counts; no aggregation-by-crafter affordance; instant swap-in-place equip | Core / UI |
| TR-bb-001 | Base Building | StructureDef schema + 12-row load validation (incl. depositPoint ≤ socketNoBuildRadius) | Data / Config |
| TR-bb-002 | Base Building | StructureInstance {instanceId, structureId, position} host-auth registry; replication representation → ADR | Networking |
| TR-bb-003 | Base Building | Client-local build mode; ghostSnapResult pure snap; per-frame non-allocating provisional F1 | Core / Performance |
| TR-bb-004 | Base Building | placementLegality 8 conjuncts, pure over ITerrainQuery + plain registry + position list; one host critical section | Core / Concurrency |
| TR-bb-005 | Base Building | demolishLegality; refund to CARRY only; storage empty-first; inert-relic reconcile | Core / Save |
| TR-bb-006 | Base Building | IStationPresenceProvider exposing exactly stationPresentExact / stationPresentAtLeast; storage placement instantiates SharedStorage | API |
| TR-bb-007 | Base Building | Walls are static 2D colliders blocking players; EC21 owner-rail one-cell correction (write mode → ADR) | Physics 2D / Networking |
| TR-bb-008 | Base Building | CI gates bootstrapRatio / perimeterSessions live-recomputed from named constants | Testing |
| TR-bb-009 | Base Building | Dashed ghost outline + reach-radius grid overlay within < 100 draw calls | Rendering |
| TR-ai-001 | Enemy AI | Host-simulated flat state machine per enemy; clients render replicated transform + telegraph events; no client AI | Networking / Core |
| TR-ai-002 | Enemy AI | Separation steering (movement layer only); aggro/leash/de-aggro heal; shore threshold absolute leash | Core |
| TR-ai-003 | Enemy AI | maxUninterruptibleTelegraphsPerTarget = 1 cap | Core |
| TR-ai-004 | Enemy AI | Composition/patrol pure function of (template, variationSeed); no respawn per docking; fully ephemeral | Determinism |
| TR-ai-005 | Enemy AI | guardDensityByBand monotonic load-check; only EnterTelegraph on the combat boundary | Config / API |
| TR-ui-001 | UI/HUD | Closed screen-space HUD list; everything else diegetic; UI Toolkit screens, world-space flyouts | UI |
| TR-ui-002 | UI/HUD | Co-op affordances: edge ally indicators, storage who-took-what, world-space ping | UI |
| TR-ui-003 | UI/HUD | Per-cause reason strings with precedence, stability, and snap hysteresis | UI |
| TR-ui-004 | UI/HUD | Schema-negative aggregation ban; gamepad nav order + focus, no hover-only; ≥ 4.5:1 contrast; no input gated by animation | UI / Accessibility |
| TR-tint-001 | Tide-Tint | One Shader Graph sprite shader for DriftLayer sprites reading global _TideTint/_TideAmount via Shader.SetGlobal*; never MPBs | Rendering |
| TR-tint-002 | Tide-Tint | DriftLayer tag taxonomy: combat/character sprites and home structures excluded by construction | Rendering |
| TR-tint-003 | Tide-Tint | Consumes tide floats each frame; any future post-process Render-Graph-native (Tier 1) | Rendering |
System Layer Map
Approved 2026-08-27 (Phase 1).
┌──────────────────────────────────────────────────────────────────────────────┐
│ PRESENTATION UI/HUD & Co-op Affordances · Tide-Tint (minimal, MVP) · │
│ Feedback Presenters (combat / harvest / build / craft VFX+SFX) │
├──────────────────────────────────────────────────────────────────────────────┤
│ FEATURE Crafting & Gear Identity · Base Building · Enemy AI │
├──────────────────────────────────────────────────────────────────────────────┤
│ CORE Player Movement · Combat · Harvesting · Inventory (Carry + │
│ SharedStorage) · Land Content (LDU: layout + registries) · │
│ Camera │
├──────────────────────────────────────────────────────────────────────────────┤
│ FOUNDATION Tide Scheduler (pure) · Netcode Session (rails, HostCommitQueue,│
│ session state) · Save & Persistence · Land Lifecycle (LDU: │
│ Addressables half) · Input (action map) · Game Data (defs + │
│ load validation) │
├──────────────────────────────────────────────────────────────────────────────┤
│ PLATFORM Unity 6.3 LTS · URP 2D (Render Graph) · NGO 2.x ⚠️ + transport │
│ · Input System · Addressables · Physics2D (Box2D) · UI Toolkit │
└──────────────────────────────────────────────────────────────────────────────┘
Structural decisions (binding)
- LDU is two modules (the TD split-watch made concrete):
LandLifecycle(Foundation — Addressables preload/activate/teardown on tide edges, memory budget) andLandContent(Core — purelayout(),KnownLandTemplates, socket authoring, the enemy spawn-contract call). Neither imports the other's concern. - Netcode Session owns a
HostCommitQueue— the single ordered channel every host-rail commit passes through, each executed as one non-yielding critical section on the host main thread (TR-net-006/007). Combat, Harvesting, Inventory, Crafting and Base Building submit command objects; none of them issue commit RPCs directly. GameDatais a Foundation module: everyDefand config table (items, recipes, structures, enemies, tide/harvest/combat/BB/inventory/movement/camera/save configs) is loaded once with the fail-loud validation tables the GDDs specify, then exposed read-only. Systems own their rules; GameData owns loading and validating* the data those rules read.- Cinemachine is not used — one inert follow camera (camera quick-spec Rule 3) does not justify the package or its 3.x API risk.
Assembly layout
| Assembly | Unity refs | Contains |
|---|---|---|
Driftlands.Tide | none | TR-tide-001..010 — the pure scheduler (DockedLands, StableHash64, deck, NextBoundary, tint floats) |
Driftlands.Data | none | *Def records, config records, the load-validation rules (fail-loud), registry lookups |
Driftlands.Rules | none | Every pure formula and legality predicate: combat F1–F5 + player state machine, harvest gates/yield/referent rule, craft legality, BB placement/demolish/FindCorrectionCell/snap, inventory stack ops + transaction check, LDU layout, enemy composition, CI-gate arithmetic. Depends on Driftlands.Tide and Driftlands.Data only. |
Driftlands.Runtime | Unity, NGO, Addressables, Input System, Physics2D | Adapters and authority: NetworkBehaviours, HostCommitQueue, session state, Save I/O, LandLifecycle, movement, camera, thin ITerrainQuery/registry/physics adapters that feed Rules with plain data |
Driftlands.Presentation | Unity, URP, UI Toolkit | HUD + screens, tint driver, feedback presenters |
Driftlands.Tests.EditMode | Tide, Data, Rules | Every [UNIT] criterion in every GDD — no scene, no NGO |
Driftlands.Tests.PlayMode | Runtime | [INTEGRATION] criteria: Multiplayer Play Mode + Network Simulator |
Why this matters: every GDD labels its formula/legality/state-machine criteria [UNIT] and several add a purity mandate (BB AC purity, combat/harvest "pure/DI-testable"). That is only achievable if the rules never reference UnityEngine. The assembly boundary makes the mandate a compile-time fact, not a discipline.
Engine-awareness flags
| Layer | Module | Domain | Risk |
|---|---|---|---|
| Foundation | NetcodeSession | NGO 2.x RPC attribute surface, transport | ✅ verified against NGO 2.13.2 source — ADR-0008 (2026-09-11) |
| Foundation | LandLifecycle | Addressables 2.x async/release | MEDIUM |
| Foundation | TideScheduler, SavePersistence, GameData, Input | pure C# / System.IO / Input System | LOW |
| Core | PlayerMovement | NetworkTransform owner-auth, OnUpdate, snap thresholds | ⚠️ HIGH — NEEDS VERIFICATION |
| Core | Combat, Harvesting, Inventory | NetworkVariable<T>, RPC intents, replication representation | ⚠️ HIGH |
| Core | LandContent | Tilemap / Tile Set (authoring only) | MEDIUM |
| Core | Camera | orthographic Camera | LOW |
Module Ownership
Approved 2026-08-27 (Phase 2).
Foundation
| Module | Owns | Exposes | Consumes | Engine APIs (risk) |
|---|---|---|---|---|
| TideScheduler | schedule math, TideConfig, t authority + monotonic guard, TideState (epoch counter + activation t) | ITideClock: Evaluate(socket) → (docked, progress), CycleIndex, EpochIndex, VariationSeed, TemplateFor, NextBoundary, TintFloats, IsDockingLive (reconcile predicate); events DockStarted, SoftClose, PreloadDue | host UTC (via Runtime adapter); TideState from Save | none (pure). Runtime adapter replicates t as NetworkVariable<long> ⚠️ |
| NetcodeSession | rail assignment, HostCommitQueue, session-scoped player state (HP/Downed/timer), join/leave flow, late-join snapshot orchestration | IHostCommitQueue.Submit(ICommand) (host executes in arrival order, one critical section each); SessionState per client; IsHost/IsOwner | every host-rail system's command types; Save write-through hook per committed command | NetworkManager, NetworkBehaviour, RPCs ⚠️ HIGH (2.x attribute surface NEEDS VERIFICATION), transport ⚠️ (post-playtest ADR) |
| SavePersistence | file format, atomic write, backups, version gate, reconcile orchestration | Save(ISaveable[]), Load() → reconcile pipeline, Persist(table) write-through — dirty-marks inside the critical section; file I/O runs off it (LP review: a synchronous serialize + file swap inside every commit collides with the 16.6 ms budget — ordering + I/O budget are ADR 4's, QQ-11) | each owner's ISaveable + reconcile predicate | System.IO, Application.persistentDataPath (LOW) |
| LandLifecycle | Addressables handles per land (one InstantiateAsync handle per slot), preload/activate/teardown timing, LandNet spawn/despawn, landTemplateBudgetMB = 64 (ADR-0005) | ILandLifecycle: LandActivated(LandInstance, root), SoftCloseDeposit, EvictionExpired, LandTornDown; StateOf, InstanceOf | ITideClock events (host); IPreloadSchedule pull (client) | Addressables.InstantiateAsync/ReleaseInstance/LoadResourceLocationsAsync (MEDIUM) |
| Input | the project-wide Driftlands.inputactions (Player / Build / UI maps; KeyboardMouse / Gamepad schemes), InputAdapter (one instance via InputSystem.actions, Update @ −1000), build-mode routing, UI capture, bindings.json (ADR-0007) | IPlayerInput.Current : InputFrame (Move, Aim, AimIsPointer, PointerWorld, edge/Held/HeldFor per action incl. Revive + Demolish, BuildModeActive, UiCaptured); IRebindService | InputConfig (input.json) | Input System 1.20 (LOW core / MEDIUM project-wide + UI Toolkit direct consumption) |
| GameData | loading + fail-loud validation of every *Def/config table; registry lookups | IGameData.Items/Recipes/Structures/Enemies/Templates/Configs (read-only) | JSON under assets/data/ (per GDDs) | file load (LOW) |
Core
| Module | Owns | Exposes | Consumes | Engine APIs |
|---|---|---|---|---|
| PlayerMovement | owner-auth transform, locomotion, aim, teleport-snap list, collision layer config | IAimSource.AimVector, IPlayerSnapper.Snap(key, reason, pos) (host) / ISnapSource.Snapped (owner), HostKnownPosition(client) | IPlayerInput; blockers from Physics2D | NetworkTransform owner-auth ⚠️ HIGH (OnUpdate, interpolation buffer, Teleport() vs interpolated write — NEEDS VERIFICATION), Rigidbody2D/Collider2D (LOW) |
| Combat | CombatState actor table (single writer, keyed by PlayerKey, session-scoped), dual-run PlayerCombatMachine, StrikeResolve/StrikeRevoke/DashStarted/Revive* commands, EnemyAttackResolver → phase 0, HostTickOrchestrator step order (ADR-0015) | ICombatState (HostContext.Combat), IPlayerCombatView, ~~IStrikeResolver~~, ~~ICombatActor~~ (HP, state), IEnemyCombat (EnterTelegraph, stagger, knockback, interrupt), events SwingLanded, DamageAbsorbed, PlayerDowned/PlayerDied | Rules.Combat formulas; IAimSource; ITideClock.Evaluate at resolution; IGearHooks from Crafting; IHostCommitQueue | NetworkVariable<TelegraphPhase> ⚠️, intent RPC ⚠️ |
| Harvesting | node state (swingsRemaining), three-gate resolution command, HarvestDiff write, referent-selection rule | IHarvestResolver, NodeView (replicated swingsRemaining), HarvestDiffTable (ISaveable, droppable) | ITideClock, ICarryInventory.CanAdd/Add, LandContent node instances, IHostCommitQueue | node replication: NetworkList<NodeNetRow> on LandNet — ADR-0010 ✅ (LP review: one NetworkList<NodeState> on the land instance is the idiomatic NGO 2.x shape; a NetworkVariable<int> per node — 40/land, ~80 with a preloading land — is rejected as an avoidable footprint) |
| Inventory | ItemStack ops, CarryInventory (owner rail), SharedStorage (host rail), transfer commands | ICarryInventory, ISharedStorage, IInventoryTx (atomic CanRemove/Remove over a CARRY ∪ STORAGE set, CanAdd/Add) | IGameData.Items (maxStack); IHostCommitQueue | owner-rail replication of CARRY, CARRY = owner fold over outcomes + CarrySync; SharedStorage = slot-indexed NetworkList<StackNetRow> on StorageNet — ADR-0010 ✅ |
| LandContent | layout(), KnownLandTemplates, socket layout (depositPoint, exclusion), traversalProfile, spawn-contract call | IKnownLandTemplates (ISaveable, permanent), ISocketLayout, LandInstance (nodes, spawn points) | ITideClock.VariationSeed/TemplateFor; LandLifecycle.LandActivated; IGameData.Templates | Tilemap / Tile Set (MEDIUM — authoring only) |
| Camera | follow, aim bias, integer zoom, hard cuts | — | IAimSource; snap events | Camera orthographic (LOW); Cinemachine not used |
Feature
| Module | Owns | Exposes | Consumes | Engine APIs |
|---|---|---|---|---|
| Crafting | recipes/legality, KnownRecipes, InstanceMetadata + gear instanceId counter, craft-commit command, durability state, equip | ICraftService, IGearHooks(instance) → DM/DR/HM, IInstanceMetadata (ISaveable, permanent), IKnownRecipes (ISaveable, union) | IInventoryTx, IStationPresenceProvider, IKnownLandTemplates, ITideClock.CycleIndex, combat/harvest durability events, IHostCommitQueue | commit RPC ⚠️ |
| BaseBuilding | StructureRegistry + structure instanceId counter, build mode (client), placement/demolish commands, stationPresent*, socket exclusion, EC21 correction trigger | IStationPresenceProvider (exactly two predicates), IStructureRegistry (ISaveable, permanent), PlaceStructure/Demolish commands | IInventoryTx, ISocketLayout, ITerrainQuery, PlayerMovement.HostKnownPosition, IHostCommitQueue; instantiates ISharedStorage | static BoxCollider2D per registry row (ADR-0011); registry = NetworkList<StructureNetRow> on in-scene BaseNet (ADR-0011 ✅); EC21 = owner-side interpolated write on the list Add event (ADR-0009/0011 ✅) |
| EnemyAI | per-enemy host state machines, steering, aggro/leash, composition from seed, telegraph-density cap | IEnemySpawner.Spawn(template, seed, points, ctx) | LandContent spawn contract; IEnemyCombat.EnterTelegraph; host-side player positions | host-only EnemyAI.Tick (step 3) over one pure EnemyBrain.Step; one NetworkObject per enemy — EnemyTransform Server-auth + EnemyNet{Phase, AnimFrame, Archetype, ActorId} (ADR-0012 ✅) |
Presentation
| Module | Owns | Exposes | Consumes | Engine APIs |
|---|---|---|---|---|
| UIHUD | closed HUD list, screens (recipes, build picker, bag/storage), reason-string display rules, co-op affordances | — | read-only views from every system (HP, bag, registry copy, tide floats + docked bools, recipe list with reasons) | UI Toolkit UIDocument/USS (MEDIUM — strict USS) |
| TideTint | the DriftLayer sprite shader + global-property driver | — | ITideClock.TintFloats per frame | Shader.SetGlobalFloat/Color (LOW); Shader Graph URP 2D (MEDIUM); no custom Render Graph pass at MVP |
| FeedbackPresenters | predicted-vs-confirmed effect timing (combat, harvest, build thunk, craft beat) | — | events from Combat/Harvesting/BB/Crafting split by authority | SpriteRenderer, particles, audio (LOW) |
Dependency diagram (arrows = depends on)
UIHUD ──┐ TideTint FeedbackPresenters
│ │ │
▼ ▼ ▼
Crafting ──► BaseBuilding EnemyAI
│ ▲ │ │ │
│ └─────────┘ │ │ (Crafting ⇄ BB: peer — Crafting reads
▼ ▼ ▼ IStationPresenceProvider; BB reads
Inventory ◄── Harvesting Combat ◄──── PlayerMovement IGameData.Items only)
│ │ │ │
└───────┬───────┴──────────┴────────┬────────┘
▼ ▼
NetcodeSession (HostCommitQueue) LandContent ◄── LandLifecycle
│ │ │
▼ ▼ ▼
SavePersistence ◄──────────── TideScheduler ◄──── (events)
▲
GameData ── Input ── Camera (leaf; reads Movement aim only)
Every arrow points downward or sideways within a layer; the one lateral peer link (Crafting ⇄ BB) is interface-only, matching the GDDs' "peer both ways" contract. No Foundation module depends on anything above it.
Data Flow
Approved 2026-08-27 (Phase 3).
3.1 Frame update path
HOST frame CLIENT frame
────────── ────────────
Input System ─► PlayerMovement (owner rail) Input System ─► PlayerMovement (owner rail)
│ own transform written locally │ own transform written locally; NetworkTransform
│ (host IS an owner for its own player) │ sends state to host (one RTT stale on arrival)
▼ ▼
Combat/Harvest/BB/Craft (owner half): Combat/Harvest/BB/Craft (owner half):
issue intents/commands ─► HostCommitQueue issue intents/commands ─► RPC ⚠️ ─► host queue
(local submit, same frame) predicted FX fire now (stance, whiff, ghost)
▼
HostTickOrchestrator (sole NetworkTickSystem.Tick subscriber — ADR-0015): tide Advance ─► CombatState.Tick (bleed-out, enemy phases, revive, home heal) ─► EnemyAI.Tick() (host only) ─► IEnemyCombat.EnterTelegraph → NetworkVariable<EnemyPhase>; EnemyAttackResolver → PendingEnemyDamage ─► Drain;
│ attacks whose resolution instant ≤ now → PendingEnemyDamage (ADR-0002)
▼
HostCommitQueue.Drain() ── hooked to NetworkManager.NetworkTickSystem.Tick (ADR-0002; fires before
│ NetworkVariable delta collection). ⚠️ The stage in which NGO applies incoming RPCs/NetworkTransform
│ relative to Tick is undocumented — measured by ADR-0002 Verification 1–2, recorded in ADR-0008:
│ 1. SoftClose set (sockets closed since last drain); cancel enemy attacks on those lands
│ 1b. phase 0: apply PendingEnemyDamage (combat AC-32 — enemy first, then player)
│ 2. for each envelope in arrivalSeq order: drop if ResolveTick ≥ SoftCloseTick(socket), else
│ cmd.Execute(ctx) → CommandResult ← single synchronous non-yielding block; re-samples every gate
│ 3. release teardown for soft-closed lands (harvesting EC6)
│ 4. flush buffered Broadcast/QueueSpawn in command order; Save.Persist dirty-marks → I/O after Drain
▼
Replication out: NetworkVariables + host-confirmed broadcasts ─────────► clients apply:
NetworkTransform.OnUpdate (interp),
NodeView/HP/telegraph vars,
confirmed FX (impact, thunk, burst)
▼ ▼
TideScheduler.TintFloats ─► TideTint.SetGlobal same, from replicated t (presentation-only)
Camera.LateUpdate (follow + aim bias) same
UIHUD reads views same
Classification: owner-rail writes are local synchronous; intents are RPC (async, ordered channel); host commit execution is synchronous, single slot; replication is NGO shared state; tint/HUD are pull reads. No data flow crosses a thread boundary — Addressables loads are the only async work and they never touch gameplay state until LandActivated fires on the main thread.
3.2 Event / signal path
| Event | Producer | Consumers | Mechanism |
|---|---|---|---|
PreloadDue(socket, template), DockStarted(socket), SoftClose(socket) | TideScheduler (host, from NextBoundary wake-up) | LandLifecycle, LandContent, Combat (state discard), Harvesting (teardown ordering), TideTint (held violet) | C# events on the host; clients receive the consequences via replication, never the event |
LandActivated(LandInstance, root) | LandLifecycle | host: after Layout.Build → Harvesting.SeedNodeState → LandNet spawn → Append (ADR-0005 order); consumers: Harvesting node views, IEnemySpawner.Spawn. Clients: raised from LandNet.OnNetworkSpawn | C# event on each machine; LandNet spawn is the NGO edge |
SoftCloseDeposit(socket) at φ+W, EvictionExpired(socket) at +EvictionGrace | LandLifecycle (host) | ADR-0009 (owner teleport to depositPoint) | host C# events |
Host-confirmed outcomes: SwingLanded/DamageAbsorbed (ADR-0015), HarvestResolved, StructurePlaced/Demolished, CraftCommitted, PlayerDowned/Died | the executing command inside HostCommitQueue | FeedbackPresenters, UIHUD, Crafting durability (SwingLanded/DamageAbsorbed), Save write-through | OutcomeRpc — one [Rpc(SendTo.ClientsAndHost)] on HostCommitChannel, flushed from drain phase 4 (ADR-0008 §3/§5); may arrive one tick before the same drain's NetworkVariable delta |
| Predicted-only: swing start, ghost preview, craft stance | owner client | FeedbackPresenters (local only) | local C# event; never replicated |
Session: ClientJoined, ClientLeft | NetcodeSession | late-join snapshot builder; session-state retention; body despawn | NGO NetworkManager callbacks (LOW) |
Rule: there is no global event bus. Each producer exposes typed C# events on its interface; presentation subscribes downward; nothing subscribes upward. The HostCommitQueue is the only fan-in point.
3.3 Save / load path
WRITE (host only, per confirmed command):
cmd.Execute ─► owner table mutates ─► Save.Persist(table) [dirty-mark only — ADR-0004]
Drain returns ─► Save.EndOfDrain: serialise dirty tables (main thread, snapshot) ─► background flush:
write-temp → fsync / F_FULLFSYNC (macOS) → File.Replace → rotate 3 backups; lastDurableDrain ← N
(durability is per-drain, broadcast-first; loss bound = wall-clock flushDeadlineMs — ADR-0004 §4)
LOAD (host, before NetworkManager.StartHost):
Save.Load()
1. version gate (saveFormatVersion, configVersion) → refuse loudly
2. clock guard: TideScheduler.Restore(TideState{lastKnownGoodT, epochIndex, epochActivationT}) (future t wins)
3. TideScheduler derives the cycle purely; the EPOCH is restored, not derived (tide EC14 — ADR-0001)
4. union reconcile: IKnownLandTemplates, IKnownRecipes (owners' predicates)
5. droppable reconcile: HarvestDiffTable via ITideClock validity predicate
6. permanent reconcile: IStructureRegistry (inert relics), IInstanceMetadata (orphans)
7. inventories: CARRY per player, SharedStorage per container id (tag-without-row → mystery gear)
8. player placement: saved position on Absent land → depositPoint; HP full; Downed cleared
then StartHost → late-joiners receive the reconciled tables (TR-net-003)
Owners implement ISaveable (serialize/deserialize + Reconcile(ctx)); Save owns only ordering and I/O. Both instanceId counters are ISaveable singletons owned by Crafting and BaseBuilding respectively.
3.4 Initialisation order
1. GameData.LoadAndValidate() — fail-loud; nothing else may start on failure
2. Input (action asset) — no dependencies
3. Save.Load() [host] — needs GameData for reconcile predicates
4. TideScheduler.Restore(TideState) — needs Save's lastKnownGoodT + epoch state; pure otherwise
5. NetcodeSession.StartHost / StartClient ⚠️ — needs tide t to replicate; HostCommitQueue armed
6. LandLifecycle.Subscribe(tide) — needs tide + Addressables ready; first evaluation may Preload immediately
7. LandContent / Inventory / Crafting / BaseBuilding registries bound to Save tables
8. Player spawn (Movement, Combat actor, CarryInventory) — needs session + reconciled CARRY
9. Camera, TideTint, UIHUD, FeedbackPresenters — presentation last; all pull-only
Clients skip step 3 and receive the tables in the late-join snapshot between steps 5 and 7.
3.5 Key scenario walkthroughs
Strike at a node with a Rusher closing (harvesting Rule 2 / combat Rule 7):
client: Input.Strike ─► Rules.Referent.Choose(aim, nearbyHostiles, nearbyNodes) → targetKind, nodeId?
Combat.IssueIntent(swingId, aim, targetKind, nodeId?) ─► predicted windup anim (phase constants by kind)
─► RPC ⚠️ ─► host queue (arrival-ordered)
host: at completion tick: SoftClose? → discard/whiff
targetKind == node → HarvestResolveCommand: Docked ∧ swingsRemaining>0 ∧ CanAdd → decrement, Add, Persist(HarvestDiff)
targetKind == enemy → StrikeResolveCommand: Rules.Combat.HitArc(current positions) → F1/F2, stagger, knockback, SwingLanded
broadcast confirmed outcome ─► presenters (burst / impact) ; rejected → nothing (honest whiff)
Wall placement (BB Rule 6):
client: BuildMode (local) ─► Rules.BB.Snap → ghost; provisional F1 every frame (no alloc) ─► Confirm ─► PlaceStructureCommand RPC ⚠️
host: queue slot: re-sample all 8 conjuncts via ITerrainQuery + registry + IHostPositions.All() (ADR-0009: raw last-received owner state, ≤ 1 RTT + 1 tick, snap-overridden)
→ IInventoryTx.Remove(cost, CARRY ∪ STORAGE-in-range-of-spot) → registry.Add → Persist → broadcast
clients: BaseNet.Structures list delta (ADR-0011) → ClientRegistry → StructureView (static BoxCollider2D on Structure + sprite, Physics2D.SyncTransforms) ; local player under footprint → Rules.Build.FindCorrectionCell → PlayerMotor.CorrectTo (EC21, interpolated write — ADR-0009)
Dock transition (tide Rule 8/9):
host: NextBoundary wake-up ─► PreloadDue ─► LandLifecycle.LoadAsync (Addressables) ─► cosmetic drift-in
at φ: DockStarted ─► Layout.Build ─► Harvesting.SeedNodeState ─► spawn LandNet ─► colliders on ─► KnownLandTemplates.Append ─► LandActivated → node views, EnemySpawner (ADR-0005; clients activate on LandNet spawn)
at φ+W: SoftClose ─► (same tick, first) combat discards ≥φ+W, harvest last-tick completes, SoftCloseDeposit → deposit at depositPoint (ADR-0009); drain step 3 → DrainReleased
+EvictionGrace: GraceExpired ∧ DrainReleased ─► EvictionExpired (EC11 backstop) ─► despawn LandNet ─► LandTornDown ─► release slot; violet held until then
API Boundaries
Approved 2026-08-27 (Phase 4). C# — these are the contracts programmers implement against. Names are pinned at implementation; shapes are binding.
// ── Foundation ────────────────────────────────────────────────────────────────
public interface ITideClock { // Driftlands.Tide — pure; Runtime adapter supplies t
(bool docked, double progress) Evaluate(int socket); // pair: progress unreadable when !docked
long CycleIndex(int socket); int EpochIndex { get; }
ulong VariationSeed(int socket, long cycleIndex);
TemplateId TemplateFor(int socket, long cycleIndex);
long NextBoundary(); // strictly > t, ≤ t + C
(float amber, float violet) TintFloats(int socket); // held (0,1) during EvictionGrace
bool IsDockingLive(int epochIndex, int socket, long cycleIndex); // the reconcile validity predicate the scheduler owns (ADR-0001, TD-ADR C3)
event Action<int,TemplateId> PreloadDue; event Action<int> DockStarted; event Action<int> SoftClose;
}
// Invariants: no raw t/φ/W/C leaves this interface (tide AC-27); consumers never cache Evaluate() across ticks.
public interface ICommand { ulong OwnerClientId { get; } uint ClientSeq { get; } CommandResult Execute(HostContext ctx); } // ADR-0002: returns CommandResult; ClientSeq owner-stamped
public readonly record struct QueuedCommand(ICommand Command, uint? ArrivalSeq); // host-assigned arrival order (BB AC-20b's optional field lives here)
public interface IHostCommitQueue { // NetcodeSession — the only fan-in (ADR-0002)
void Enqueue(CommandEnvelope env, ulong ownerClientId); // host only; wire entry is HostCommitChannel.SubmitRpc — ONE [Rpc(SendTo.Server)] on ONE NetworkObject
void Drain(HostContext ctx); // once per NetworkTickSystem.Tick
// Guarantee: Drain() runs once per host tick in the pinned phase (TR-net-008), executes commands in
// arrival order (ArrivalSeq; lowest OwnerClientId only when seq is absent), each Execute() is one
// synchronous non-yielding block, SoftClose is evaluated before any command on that tick.
// Callers must: re-sample every gate inside Execute; never hold state across commands; never await.
}
public interface ISaveable { string TableId { get; } SaveClass Class { get; } /* Permanent | Droppable */
void Serialize(ISaveWriter w); void Deserialize(ISaveReader r); void Reconcile(ReconcileContext ctx); }
public interface IGameData { IReadOnlyDictionary<ItemId,ItemDef> Items { get; } /* Recipes, Structures, Enemies, Templates, Configs */ }
public interface IPlayerInput { InputFrame Current { get; } } // ADR-0007: one immutable per-frame snapshot (Driftlands.Rules.Input) — edges computed once; edge consumers run in Update only
// ── Core ──────────────────────────────────────────────────────────────────────
public readonly record struct StrikeIntent(uint SwingId, Vector2 Aim, TargetKind Kind, NodeId? Node);
// IStrikeResolver RETIRED by ADR-0015 — StrikeResolveCommand IS the resolver; node-kind intents are routed to HarvestResolveCommand at receipt. StrikeIntent above lives in Driftlands.Data (shared vocabulary, neither system owns it); IntentTick is host-stamped, never in the payload.
public interface ICombatState { /* ADR-0015: host-owned actor table keyed by PlayerKey/EnemyId — Player/Enemy views, HostSeesDashing, MirrorStep, ApplyHit, ApplyEnemyDamage, TryStartRevive/CancelRevive, RevokeSwing */ }
public interface IPlayerCombatView { int Hp { get; } int MaxHp { get; } PlayerState State { get; } float BleedOutRemaining { get; } } // HUD/presenters — never enemy HP; SwingLanded/DamageAbsorbed are raised from the drain flush only (replaces ICombatActor — ADR-0015) // two payload records (ADR-0003, TD-ADR C4)
public interface IEnemyCombat { void EnterTelegraph(AttackId id); bool Interruptible { get; } float Meter { get; } EnemyPhase Phase { get; } } // ADR-0015: Meter is a float (F4 operates on floats); Phase exposed for enemy-ai Rule 6's cap check; EnterTelegraph is the ONLY AI→combat call
public interface IAimSource { Vector2 AimVector { get; } } // Movement → Combat, BB ghost, Camera — RETAINED by ADR-0007 as a Movement pass-through (AimVector => input.Current.Aim); aim is computed in Rules.Input.AimResolver
public interface IHostPositions { Vector2 HostKnown(ulong clientId); } // exact for host's own player, one-RTT stale for remotes (BB F1)
public readonly record struct ItemStack(ItemId Id, int Count, InstanceTag? Tag);
public interface ICarryInventory { bool CanAdd(ItemId id, int n); void Add(ItemStack s); bool CanRemove(ItemId id, int n); void Remove(ItemId id, int n); int Count(ItemId id); }
public interface ISharedStorage : ICarryInventory { ContainerId Id { get; } int ContentCount { get; } }
public interface IInventoryTx { // atomic over a CARRY ∪ STORAGE-set; host only, inside Execute
bool CanRemove(IEnumerable<(ItemId, int)> cost, ICarryInventory carry, IReadOnlyList<ISharedStorage> inRange);
void Remove(...); bool CanAdd(ICarryInventory target, ItemStack s); }
public interface IKnownLandTemplates : ISaveable { bool Contains(TemplateId t); void Append(TemplateId t); } // append-only, seeded
public interface ISocketLayout { IReadOnlyList<Socket> Sockets { get; } } // Socket = (index, position, depositPoint, noBuildRadius)
public interface IEnemySpawner { void Spawn(TemplateId t, ulong variationSeed, IReadOnlyList<SpawnPoint> pts, ScalingContext ctx /* empty at MVP */); }
// ── Feature ───────────────────────────────────────────────────────────────────
public interface IStationPresenceProvider { // BaseBuilding → Crafting; exactly these two, never a mode arg
bool StationPresentExact(StationTier tier, Vector2 asker);
bool StationPresentAtLeast(StationTier tier, Vector2 asker); } // never cached host-side
public interface IGearHooks { float DamageModifier(InstanceId i); float DamageReduction(InstanceId i); float HarvestModifier(InstanceId i); } // dull → tier baseline
public interface ITerrainQuery { bool IsBuildable(Cell c); bool InPlaceableRegion(Cell c); } // plain-data adapter; Rules never touch Tilemap
public interface IStructureRegistry : ISaveable { IReadOnlyList<StructureInstance> All { get; } bool AnyCovers(Cell c); }
Boundary invariants (binding):
Driftlands.Rulesfunctions take and return plain data only — every interface above that Rules consumes is a data snapshot passed in by Runtime.- Anything
⚠️-marked (RPC shape, replication representation,NetworkTransformwrite mode) is an ADR input; the interface is fixed, the wire is not. Vector2/Cellinside Rules are Driftlands structs, notUnityEngine.Vector2— the assembly has no Unity reference.- Every host-rail mutation is reachable only through an
ICommandexecuted byIHostCommitQueue; a direct setter on a host-authoritative table from outside a command is a boundary violation (review checklist + analyzer candidate) — with exactly one registered exception: the tide-edge carve-out (ADR-0005 §3:LandNetspawn/despawn andIKnownLandTemplates.Append, driven by Authority-clock tide events insideAdvance).
ADR Audit
Phase 5, 2026-08-27: no ADRs existed. Superseded by /architecture-review 2026-08-28 (architecture-review-2026-08-28.md): 8 ADRs audited — 8/8 carry Engine Compatibility on 6000.3, 0 deprecated-API references, 3 cross-ADR conflicts (C1–C3, amended same day), 13 specialist findings (E1–E13).
Traceability Coverage
2026-08-28: 111 requirements — 64 covered, 28 partial, 19 gaps (Foundation layer: 0 gaps). Full matrix in traceability-index.md; stable IDs in tr-registry.yaml. Note TR-craft-003/004/006/008 (crafting runtime half) are assigned to no row below — see the review's Required ADRs (extend ADR-0010 or add row 16).
Required ADRs
Phase 6, 2026-08-27. ⏳ = the wire half is gated on the friend-group playtest (netcode-session Rule 7); the model half can be drafted as Proposed now. ⚠️ = HIGH-risk engine domain, verification step required before Accepted.
Foundation — must exist before any coding
| # | ADR | Covers | Gate |
|---|---|---|---|
| 1 | Tide Scheduler as a pure engine-free assembly — Driftlands.Tide, typed math, TideConfig versioning, host t replication adapter, perf gates (AC-44/45 hard, AC-46 advisory — TD ruling) | TR-tide-001..010 | none |
| 2 | Host commit model: single ordered HostCommitQueue, non-yielding critical sections, tick-phase ordering (TD-C1 + TD-C2) — pinned execution phase for Drain vs NetworkTransform receive vs owner writes (candidates: FixedUpdate, a NetworkUpdateStage registration, NetworkTickSystem.Tick — must verify NGO 2.x still exposes NetworkUpdateLoop/NetworkUpdateStage on 6000.3; the project reference never checked it); arrival-seq/tiebreak rule; SoftClose-first; enforcement (analyzer or review rule) | TR-net-006/007/008, TR-combat-010, TR-harv-002/005, TR-craft-003, TR-bb-004, TR-inv-003 | none — answerable from NGO NetworkUpdateLoop docs ⚠️ verify |
| 3 | Assembly + purity boundaries — Tide/Data/Rules/Runtime/Presentation/Tests asmdefs; Rules has no Unity refs; plain-data adapters (ITerrainQuery, IHostPositions); one Runtime-owned UnityInterop conversion utility (Driftlands Vector2/Cell ↔ UnityEngine); shapes for the five referenced-but-undefined types — HostContext, ReconcileContext, Cell, LandInstance, HitEvent — plus one worked concrete command class showing intent-time fields vs Execute-time re-sampled state (LP review, QQ-12); gridCellSize/snap constants pinned | TR-combat-005, TR-bb-003/004, TR-harv-004/005, TR-ai-004, TR-ldu-002, TR-craft-009, TR-move-006 | none |
| 4 | Save file format + reconcile pipeline — JSON vs binary, location, write-temp-then-swap, backups, version gate, ISaveable + two-class rule, the 8-step order, both instanceId counters; resolves the TR-net-007 ↔ TR-save-003 tension: Persist dirty-marks synchronously, I/O runs off the critical section (Awaitable/worker) with in-order completion and a stated per-write budget; "confirmed" durability semantics defined (LP review, QQ-11) | TR-save-001..006, TR-tide-004, TR-harv-003, TR-craft-002/004, TR-ldu-003, TR-inv-005, TR-bb-005 | none |
| 5 | Land lifecycle on Addressables — group/label scheme (one atlas per template + core), preload at NextBoundary − PreloadLeadTime, teardown at +EvictionGrace, release discipline, per-template MB budget owner + number (TD-C4) | TR-ldu-001/007, TR-tide-006 | none (MEDIUM — Addressables 2.x) |
| 6 | Game data loading + fail-loud validation — assets/data/*.json → Driftlands.Data; every GDD validation table as one boot gate; material→source-template mapping owner (crafting Rule 3b) | TR-tide-009, TR-combat-012, TR-harv-006, TR-craft-001/005/007, TR-bb-001, TR-ai-005, TR-ldu-004 | none |
| 7 | Input action map — one .inputactions, Player + Build maps, KB/M + gamepad schemes, decoupled aim, rebind persistence | TR-move-001, TR-ui-004 (gamepad nav) | none (LOW) |
Core — before the relevant system is built
| # | ADR | Covers | Gate |
|---|---|---|---|
| 8 | ✅ ADR-0008 (2026-09-11, Accepted under the Gate 0 provisional pass) — Netcode wire: transport, RPC shapes, replication representations — Steam transport package; the 2.x RPC attribute surface ⚠️; per-state NetworkVariable-vs-RPC choices (telegraph phase, swingsRemaining, HP, tide t); late-join snapshot mechanics; intent-validation bounds; send rates; relevancy = none | TR-net-001..005, TR-combat-002/003/007, TR-harv-001/007, TR-craft-008, TR-ai-001, TR-tide-003 | written 2026-09-11; friend playtest = confirmation (revisit clause) |
| 9 | ✅ ADR-0009 (2026-09-11, Accepted under the Gate 0 provisional pass) — Player transform authority — NetworkTransform owner-auth config, OnUpdate interpolation, remoteInterpBuffer, the closed teleport-snap list, EC21 write mode (Teleport() vs interpolated — ~30-min prototype check first, TD-C3) | TR-move-002/004/005, TR-combat-008, TR-bb-007 | written 2026-09-11; §6 interpolation provisional pending the playtest CSV; QQ-02 = its V2 |
| 10 | ✅ ADR-0010 (2026-09-11, Accepted under the Gate 0 provisional pass) — Inventory replication — CARRY owner-rail representation + prediction; SharedStorage host-rail representation (component / NetworkObject / replicated record); IInventoryTx implementation | TR-inv-001..004, TR-bb-006 (storage instantiation) | partially ⏳ (representation rides ADR 8); micro-spike recommended (TD-C5) |
| 11 | ✅ ADR-0011 (2026-09-11, Accepted under the Gate 0 provisional pass) — Structure registry replication + wall colliders — NetworkObject-per-structure vs replicated list; snapshot-vs-in-flight ordering; static Collider2D instantiation; Physics2D layer matrix (no player/enemy body collision) | TR-bb-002/007, TR-move-003 | ⏳ rides ADR 8 |
| 12 | ✅ ADR-0012 (2026-09-11, Accepted under the Gate 0 provisional pass) — Enemy simulation model — host-only flat state machines, separation steering, transform replication cadence, spawn contract from LandContent, scalingContext reserved | TR-ai-001..005, TR-ldu-005 | partially ⏳ (cadence) |
Presentation — before the relevant screen/shader is built
| # | ADR | Covers | Gate |
|---|---|---|---|
| 13 | Tide-tint render pipeline (minimal, MVP) — Shader Graph URP 2D sprite shader, global _TideTint/_TideAmount, DriftLayer tag taxonomy with the combat-sprite/home-structure exclusion, no custom Render Graph pass; BB ghost/grid overlay within the draw-call budget (TD-C4) | TR-tint-001..003, TR-tide-008, TR-bb-009, TR-cam-001 | ADR-0013 (Accepted 2026-09-01) — the HIGH-domain verification was executed and it changed the design: both globals must be declared at Scope = Global on the Shader Graph blackboard (review E14; Show In Inspector is not the control) |
| 14 | UI stack — UI Toolkit for screens, world-space flyouts (UGUI or sprites), USS linting, reason-string precedence/hysteresis contract, aggregation-ban schema check | TR-ui-001..004, TR-combat-013 | ADR-0014 (Accepted 2026-09-01) — world-space UI resolved to sprites (not UGUI); no uGUI EventSystem anywhere; world-space UI draws from the separate UI atlas at ≈ +2 draw calls (review F1) |
| 15 | Combat model — owner of ICombatState (HP/state per actor, stagger meters, ApplyDamage, Downed/Dead/revive transitions, PendingEnemyDamage production for ADR-0002 phase 0), StrikeResolveCommand, revive-channel edges; added 2026-08-28 (ADR-0003 TD-ADR C7 — the largest un-owned surface in HostContext) | TR-combat-001/003/005/006/008/009/011, TR-ai-003 | partially ⏳ (telegraph replication rides ADR 8) |
Can defer to implementation
Camera follow implementation (TR-cam-002 — a script, not a decision); feedback-presenter timing (TR-combat-004, TR-harv-007 presentation halves); CI-gate test harness (TR-bb-008, TR-ldu-006 — test code).
Coverage: 102/102 requirements assigned. ADRs 1–7 and 13–14 (9 of 14) are writable now; ADRs 8–12 can be drafted Proposed today and finalised after the playtest.
Architecture Principles
- The world is a save file plus a pure function. No offline simulation, no schedule state; anything derivable from
(seed, t, config)is never stored or replicated (concept, tide GDD, Forbidden Patterns). - Two rails, one fan-in. Owner rail for what you own (movement, bag, predicted FX); host rail for what the world owns — and every host-rail mutation is an
ICommandthrough the singleHostCommitQueue, executed as one non-yielding synchronous block. No third rail, no side doors. - Rules are engine-free. Formulas, legality predicates, state machines, and seeded content generation live in assemblies with no
UnityEnginereference; Unity is an adapter. This is what makes every[UNIT]acceptance criterion honest. - Re-sample at resolution, never cache.
Docked, positions, gates,stationPresent— every host decision reads current state inside the command that acts on it. Client-side copies are for display only and may never gate a commit. - Fail loudly at load, never silently at runtime. All game data passes its GDD validation table at boot; a config that violates an invariant stops the game with a named error. Schema-negatives (no
MoveSpeedModifier, no builder field, no craft counts, no home nodes) are reflection tests, not conventions. - HIGH-risk engine surfaces are ADR inputs, not assumptions. Every NGO 2.x and Render Graph touchpoint carries a verification step against the 6000.3 docs before its ADR moves to Accepted.
Open Questions
| ID | Summary | Priority | Resolution path |
|---|---|---|---|
| QQ-01 | Exact host execution phase for HostCommitQueue.Drain vs NetworkTransform receive vs owner movement write (TD-C2) | High | ADR 2 — from NGO 2.x NetworkUpdateLoop docs ⚠️ |
| QQ-02 | Does a one-cell NetworkTransform write survive snap thresholds as an interpolated update (EC21 observer asymmetry, TD-C3)? | Closed 2026-09-11 | ADR-0009 §5 ratified the interpolated write (no receive-side snap threshold in NGO 2.13.2 source); V2 verifies in play |
| QQ-03 | NGO 2.x RPC attribute surface ([Rpc(SendTo.…)] vs ServerRpc/ClientRpc) — project reference shows 1.x | Closed 2026-09-11 | ADR-0008: [Rpc(SendTo.…)] verified against the 2.13.2 source; InvokePermission supersedes the deprecated RequireOwnership |
| QQ-04 | ~~Steam transport package (Facepunch / Heathen / Unity relay)~~ **RETITLED 2026-09-02: "which Steam transport, when we ship" — downgraded High→Low and taken OFF the playtest critical path. Research (2026-09-02) established the load-bearing fact: NGO's NetworkTransport abstract surface is stable across 1.x→2.x** — verified directly against the NGO develop source: exactly 10 abstract members (ServerClientId, Send, PollEvent, StartClient, StartServer, DisconnectRemoteClient, DisconnectLocalClient, GetCurrentRtt, Shutdown, Initialize), IsSupported virtual, and NGO 2.2's OnEarlyUpdate/OnPostLateUpdate are optional additions, not abstract requirements. The anticipated "NGO 2.0 broke custom transports" is not true — the real historical break was NGO 1.0.0-pre.6 (2022), long since absorbed. Therefore the transport is swappable after the playtest without invalidating its findings, because the netcode model (owner-auth movement, host-auth damage, local-only prediction) sits entirely above a pure byte-pipe. Decision: run the playtest on UTP + Unity Relay (first-party, tracks NGO 2.x exactly, free at 50 CCU vs. a 2-player need, no App ID, no router config, ~35 lines behind an ISessionLauncher seam). Relay's extra hop lands RTT naturally in the 30–70 ms target band — record measured GetCurrentRtt during the playtest so a later swap has a baseline. When Steam is revisited: prefer Steamworks.NET (2025.164.1, SDK 1.64, MIT, actively maintained, single native plugin set) over Facepunch, whose community transport has an open unfixed duplicate-assembly conflict (multiplayer-community-contributions#219) that bites exactly the Win64/Posix cross-platform pair this project needs; Heathen's NGO integration is undocumented (KB page 301s away). Budget 2–3 days worst case for a hand-rolled transport — 10 members map near 1:1 onto SteamNetworkingSockets. | Low | ADR-0008 (ship-time, not playtest-time) |
| QQ-05 | SharedStorage and structure-registry replication representation | Medium | ADRs 10/11, after ADR 8 |
| QQ-06 | ~~Per-template asset MB budget owner and number~~ RESOLVED by ADR-0005: LandLifecycle owns landTemplateBudgetMB = 64, CI-gated on the Addressables build layout (non-vacuous). Build-overlay draw-call cost RESOLVED by ADR-0013 §8: ghost 0 (home atlas, pre-authored dash frames) + grid 1 (one procedural mesh) = +1 draw call, build mode only. QQ-06 fully closed 2026-09-01. | Low | ADR-0005 + ADR-0013 |
| QQ-07 | ~~Save format: JSON vs binary; file location~~ RESOLVED by ADR-0004: JSON via Newtonsoft behind ISaveWriter/ISaveReader (STJ not in the 6.3 BCL); persistentDataPath/Driftlands/worlds/<worldId>/save.json + 3 rolling backups | — | ADR-0004 |
| QQ-08 | ~~Material→source-template mapping owner (crafting era gate)~~ RESOLVED by ADR-0006: ItemDef.SourceTemplates in crafting.json, owner Crafting; referential check at validation stage 7; no reverse copy on TemplateDef | — | ADR-0006 |
| QQ-09 | ~~gridCellSize / snap tolerance (BB OQ6)~~ RESOLVED by ADR-0003: 1 cell = 1 world unit = one 32 px tile at PPU 32 | — | ADR-0003 |
| QQ-10 | ~~Shader Graph 2D sprite shader global-property read verified in URP 6.3 (no Render Graph pass needed)~~ RESOLVED 2026-09-01 by ADR-0013 — verified against live 6.3 docs, and the verification changed the design: the read works only if both properties are declared at Scope = Global on the blackboard (Show In Inspector is a visibility control and does NOT prevent material shadowing — architecture review E14). No Render Graph pass is needed, as assumed. | Medium | ADR-0013 |
| QQ-11 | ~~Save write-through vs non-yielding commit~~ RESOLVED by ADR-0004: dirty-mark in the critical section, serialise at end-of-drain on the main thread, flush on a background thread; durability per-drain, broadcast-first, wall-clock-bounded by flushDeadlineMs | — | ADR-0004 |
| QQ-12 | ~~Shapes of HostContext, ReconcileContext, Cell, LandInstance, HitEvent; the UnityInterop utility; a worked command example~~ RESOLVED by ADR-0003 (HitEvent became SwingLanded + DamageAbsorbed) | — | ADR-0003 |
| QQ-13 | Harvest-node replication: NetworkList<NodeState> per land vs per-node NetworkVariable (LP review 4) | Closed 2026-09-11 | ADR-0010 §4: NetworkList<NodeNetRow> on LandNet, index-aligned with LandInstance.Nodes |