ADR-0011: Structure Registry Replication — BaseNet List, Client Registry Copy, Wall Colliders, and the Chest Binding
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 (NetworkList<T>, in-scene NetworkObject) · Unity 2D Physics (BoxCollider2D, layer matrix) |
| Domain | Networking (Core data replication) + Physics 2D (static blockers) |
| Knowledge Risk | HIGH for NGO list semantics — but every fact this ADR relies on was read from the 2.13.2 source for ADR-0010 (2026-09-11): NetworkList<T> constraint unmanaged, IEquatable<T>; spawn-time full-contents sync via WriteFieldSynchronization → WriteField; Add/Remove/indexer emit one event + one delta each; NativeList grows on demand. LOW for 2D static colliders (unchanged for years). New in this ADR and verification-required: in-scene placed NetworkObjects and their variables at scene synchronisation for a late joiner (V1). |
| References Consulted | docs/engine-reference/unity/VERSION.md, breaking-changes.md, deprecated-apis.md, modules/physics.md (layer matrix); ADR-0008 (§3 HostCommitChannel is itself an in-scene object, §5 rule, §6 snapshot, NetworkObject.InScenePlaced), ADR-0009 §3 (Structure layer, sweep, Physics2D.queriesStartInColliders = false, matrix fence), ADR-0010 (StorageNet, SpawnRequest.SharedStorage, ContainerId = "s"+instance, ItemIndexTable pattern, CarryFold over StructurePlaced/Demolished), ADR-0003 (worked PlaceStructureCommand, RegistrySnapshot, TilemapTerrainQuery, Schema_* AC-24 allow-list), ADR-0004 (stage 6 permanent reconcile, structures table, EC13/15/16), ADR-0002 (deferred effects), ADR-0006 (structures.json validation, IGameData.Structures); design/gdd/base-building.md Rules 1/4/6/8/9/12/13, F1/F2, EC13/15/16/18/21/22, AC-24/27/39b/44; design/ux/build-mode.md (picker, ghost, reason strings, demolish preview); shipped code: Rules/Commands/HostContextServices.cs (IStructureRegistry, IStationPresenceProvider, ITerrainQuery placeholders; ISocketLayout), Data/Tables/Ids.cs (StructureId is a string), Data/Tables/DefMarkers.cs (StructureDef marker), Rules/BuildOverlay/ReachGridMesh.cs (build overlay already reads placementReach) |
| Post-Cutoff APIs Used | NetworkList<StructureNetRow> server-write on an in-scene NetworkObject (InScenePlaced, ADR-0008); NetworkListEvent<T>.EventType.{Add, Remove, RemoveAt} + Index/Value (src, ADR-0010); NetworkObject.Spawn()/Despawn(true) for StorageNet (ADR-0010). No new NGO API. |
| Verification Required | (V1) Confirmed from source (specialist, 2026-09-11): a late joiner receives every spawned object's variables — in-scene included — through NetworkSceneManager.SynchronizeNetworkObjects (SceneEventType.Synchronize, ~1978/2320) → NetworkBehaviour.WriteNetworkVariableData → WriteFieldSynchronization (full contents, ~1378–1411); NetworkConfig.EnableSceneManagement defaults to true (src 109) and ADR-0008 leaves it. Precondition (CHANGELOG #3177): the scene holding BaseNet must be loaded and the list filled before StartHost — the in-scene object table is populated at manager start. Story 1 keeps the Structures.Count assertion in the joiner's OnNetworkSpawn. (V2) Confirmed with a caveat (specialist): Remove(T) builds NetworkListEvent{Type = Remove, Value = item} and never assigns Index (src NetworkList.cs 498–522) — the field is 0/meaningless on that event, and m_List.RemoveAt shifts every later element. Clients therefore key exclusively on Value.InstanceId and must never read Index on a Remove; fenced (§8 Bld_NoIndexOnRemove). (V3) A BoxCollider2D created and positioned in the Add handler (main thread, NGO message processing — outside FixedUpdate) is queryable by Rigidbody2D.Cast in the same frame only once the physics world has synced the new shape: StructureView calls Physics2D.SyncTransforms() right after creating the collider (specialist note; Physics2D.autoSyncTransforms' default is an engine-global setting this ADR does not rely on — the explicit call is the guarantee). PlayMode asserts: place → next Sweep.Move stops; a companion negative without the sync call shows the frame gap. (V4) Row ↔ StorageNet order independence: a storage row arriving before or after its StorageNet spawn binds correctly either way (two objects, no ordering relation — §4). (V5) ~100 rows (1.4 KB) in one scene-sync message stay well under MaxPayloadSize (6 144 B); asserted so growth past EC18's "low hundreds" is noticed. |
Revisit clause (Gate 0)
No section is feel-dependent. The one-tick state lag (ADR-0008 §2) means a client may receive StructurePlaced one tick before the row that creates the collider; §3 makes presenters act on the outcome and movement on the collider, so the lag is invisible except as ≤ 20 ms of "the thunk before the wall".
Documents Amended by This ADR
| Document | Amendment |
|---|---|
| ADR-0008 | §5 structure-registry row decided: NetworkList<StructureNetRow> on the in-scene BaseNet; §6 SnapshotDocument drops the structures table (live list is authoritative — same reasoning as storage/nodes); the snapshot header's itemIndexHash becomes dataHash covering the item and structure index tables; OutcomeKind gains StructurePlaced, StructureDemolished with the fields in §5. |
| ADR-0010 | The ContainerId = "s" + structureInstanceId constraint is accepted; StorageNet.StructureInstance is the join key to the registry row (§4). CarryFold reads StructureDef.Cost for StructurePlaced and the refund (100 % of current cost, zero for relics) for StructureDemolished — the outcome carries RefundIsZero so the fold needs no relic lookup. |
| ADR-0009 | §3's blocker set is instantiated here: one static BoxCollider2D per registry row on Structure; ADR-0009's EC21 trigger fires from the list Add event (the collider's appearance), never from the StructurePlaced outcome (§6). |
| ADR-0003 | IStructureRegistry (placeholder) gets its shape (§Key Interfaces): All, AnyCovers, TryGet, Add, Remove; IStationPresenceProvider gets exactly the two predicates (AC-44); ITerrainQuery gets IsBuildable/InPlaceableRegion; new Runtime types BaseNet, RegistryReplicator, ClientRegistry, StructureView, StructureIndexTable, StationPresence; Rules.Build namespace declared (F1–F4, Footprint, FindCorrectionCell — the pure functions ADR-0003 already assigned there). HostContext gains no member. |
| ADR-0004 | Stage 6's inert relic is made concrete: the structures table writes a footprint column (w, h) per row from the def at save time — a writer column, never a StructureInstance field (AC-24 literal) — so a relic keeps its full silhouette, collision and noOverlap after its def is removed (user decision 2026-09-11). Reconcile: a row whose def is missing keeps its column values; a row whose def exists is re-derived from the def (data wins for live structures). |
design/gdd/base-building.md | EC13: "footprint preserved" is honoured by a save-table column, not an instance field (AC-24 untouched); EC16's ⚑ closes (host-monotonic counter persisted; replicated as the row's InstanceId); Rule 12's "component / NetworkObject / replicated record" question closes: a registry row + a StorageNet object (ADR-0010). |
docs/registry/architecture.yaml | wire_id_codecs revised: itemIndexHash → dataHash (items, structures, appended tables); entries added per Registry candidates. |
architecture.md | Required-ADR row 11 ✅; §2 BaseBuilding row loses both ⚠️; §3.5 wall-placement scenario's "registry replication ⚠️(ADR)" → BaseNet.Structures. |
UnityProject/Assets/Driftlands/Data/Configs/GameConfigs.cs / DefMarkers.cs | BuildConfig and StructureDef placeholders get their fields (Rule 1 + knobs) in the BaseBuilding epic's Data story; structures.json joins RequiredFiles (+ both TestJson fixtures). |
ADR Dependencies
| Field | Value |
|---|---|
| Depends On | ADR-0008 (rule, outcomes, snapshot, in-scene objects), ADR-0009 (Structure layer, sweep, EC21 CorrectTo), ADR-0010 (StorageNet, SpawnRequest, ContainerId, index-table pattern, fold), ADR-0002 (commands, deferred effects), ADR-0003 (HostContext members, purity), ADR-0004 (permanent table), ADR-0006 (structures.json) |
| Enables | ADR-0012 (nothing structural — enemies never enter the home island at MVP; the Structure layer blocks them by the same matrix), the BaseBuilding epic (every story), presentation-ui-hud-screens build picker / placement (reads ClientRegistry + StructureIndexTable), crafting runtime (IStationPresenceProvider on the host; advisory reads on the client) |
| Blocks | BaseBuilding epic; HUD build/placement screens; crafting's station gate; the BB EC21 story in the PlayerMovement epic |
| Ordering Note | Fourth of the five. ADR-0012 is independent of it. |
Context
Problem Statement
StructureInstance = {instanceId, structureId, position} "is the whole registry" (BB), host-authoritative, permanent in the save, low hundreds of rows at most (EC18). Every client needs a copy: a wall's entire function is collision (Rule 8, AC-27) and it must block "the instant its host-confirmed placement lands"; the ghost's provisional F1 and crafting's advisory stationPresent read a client copy that is "legal for display, never for gating" (Rule 13, AC-39b); a storage structure is also a StorageNet (ADR-0010) that must bind to its row; a late joiner needs the whole base before it can walk into a wall. StructureId is a string and list rows must be unmanaged. Row 11 of the architecture asks: list vs NetworkObject per structure, snapshot-vs-in-flight ordering, static collider instantiation, the layer matrix.
Constraints
- ADR-0008 §5: state = server-written list on the owning object; edges = outcomes; never both for one fact.
- BB AC-24:
StructureInstanceis exactly three fields (fenced); no builder, no cost snapshot (EC14), no footprint. - BB Rule 13 / AC-39b: the host commit-instant evaluation is authoritative and never cached; a client copy is advisory only and a client never pre-validates in place of the host.
- ADR-0009 §3: blockers are static colliders on
Structure; the matrix is fenced; EC21 is an owner-side write triggered when a wall lands on the player's cell. - ADR-0010:
StorageNetper chest, spawned byPlaceStructureCommandviaQueueSpawn, despawned viaQueueDespawn;ContainerId = "s"+instance. - ADR-0004 stage 6: permanent reconcile — unknown
structureId→ inert relic, never dropped;instanceIdcounter persisted. - Gameplay values are data (
structures.json, ADR-0006):StructureDef,placementReach,socketNoBuildRadius,perimeterSegments;stationRangeis crafting's knob.
Requirements
TR-bb-002 (registry replication representation), TR-bb-005 (demolish, refund to CARRY, storage empty-first, relic reconcile — the wire half), TR-bb-006 (two station predicates; storage placement instantiates a container), TR-bb-007 (walls are static 2D colliders; EC21 write mode — decided by ADR-0009, trigger decided here), TR-move-003 (blocker set), TR-craft-003's station gate input.
Decision
**Alternative A: one in-scene BaseNet NetworkObject carries NetworkList<StructureNetRow{uint InstanceId, ushort StructureIndex, short X, short Y, byte W, byte H}> mirrored from the host StructureRegistry after every drain by one writer; every machine (host included) builds a ClientRegistry from the list's events and instantiates one plain StructureView per row — a static BoxCollider2D on the Structure layer sized to the def's footprint plus the sprite — created on Add, destroyed on Remove; storage rows bind lazily to the StorageNet whose StructureInstance matches; StructureId crosses as an index from a StructureIndexTable hashed into the snapshot header; relics are rows whose def is absent on the receiving machine and render as the fallback visual at the row's own footprint; stationPresent* is evaluated on the host over the live registry and on clients over the copy for display only; the snapshot drops the structures table.**
1. The row and the codec
StructureNetRow { uint InstanceId; ushort StructureIndex; short X; short Y; byte W; byte H; } — 14 B, unmanaged, IEquatable. W, H are the footprint as it was when the row was written (from the def at placement; from the save column on load) — carried so a relic keeps its silhouette after its def is gone (user decision 2026-09-11). StructureIndex comes from StructureIndexTable (Runtime): the ordinal-sorted key order of IGameData.Structures at data load — the ItemIndexTable pattern (ADR-0010 §1); its hash is folded with the item table's into the snapshot header's dataHash (ADR-0008 amended) — dataHash = FNV-1a over the concatenation of each index table's own hash in a fixed table order: items, structures, then any table a later ADR adds, appended, never reordered (ADR-0012 appends its enemy/template table rather than defining a second header field; TD C8) so a client whose structures.json differs disconnects with ConfigVersion instead of mis-mapping a wall to a chest. X, Y are the footprint's anchor cell (Cell, min corner — ADR-0003 §2). Relic rows (BB EC13): a structureId absent from current data has no index — the host's StructureIndexTable returns 0xFFFF and the string stays only in the host's save table; every machine renders 0xFFFF as the generic relic visual at the row's own W × H, and the relic blocks noOverlap, movement and FindCorrectionCell over that whole footprint — EC13's "position/footprint preserved" holds as written. The footprint survives the patch because the structures save table writes a footprint column per row (ADR-0004 stage 6; a table column, written from the def at save time, not a field of StructureInstance — AC-24's allow-list stays literal). Live rows carry W, H too (redundant with data, self-describing on the wire, 2 B).
2. Host truth and the mirror
- Truth:
StructureRegistry : IStructureRegistry, IReconcilable(Runtime.Build) — the permanentstructuressave table (ADR-0004 stage 6) plus the host-monotonicinstanceIdcounter (BB Rule 16 / EC16, persisted). Written only insidePlaceStructureCommand.Execute(Add) andDemolishCommand.Execute(Remove) — ADR-0002 invariant 4.HostContext.Structuresis this object; every F1/F2 conjunct re-samples it at commit (Rule 13: never cached host-side). - Mirror:
BaseNet.Structures : NetworkList<StructureNetRow>(server-write) on the in-scene, always-spawnedBaseNetobject (its ownNetworkObject— bulk state never sharesHostCommitChannel, ADR-0008'sbulk_payload_on_command_channel).RegistryReplicator(host) runs once after everyDrain()in the same phase-4 window asStorageReplicator(ADR-0010 §3) and applies the drain's registry diff:Structures.Add(row)per new instance,Structures.Remove(row)per removed one (by value — V2 — so clients key onInstanceId, never on list index; order is placement order and carries no meaning). LikeStorageNet.Rows, the list is a mirror, not host-rail state: no gate reads it, no table holds it, it is reconstructible fromStructureRegistryat any instant. - Boot (host): after
Save.Load()reconcilesstructures(relics kept),BaseNet.Structuresis filled from the registry beforeStartHost— a hard precondition, not a preference: NGO populates its in-scene object table at manager start (CHANGELOG #3177), so the boot scene holdingBaseNetmust be loaded and filled first (V1). BeforeStartHostthere is no session:Initialisepopulates the backing list directly — it is not a replicated write and does not pass throughRegistryReplicator, which is the only writer once the session is live;Initialisethrows ifNetworkManager.Singleton.IsListening(TD C1; companion negative ofBld_ListSingleWriter), and every storage row'sStorageNetis spawned with its rows pre-filled (ADR-0010 §3 "Load"). - Late join: the joiner receives
BaseNetwith the full list during synchronisation (V1); the snapshot carries nostructurestable (ADR-0008 amended). A placement in flight during the join is an ordinary later delta. There is no snapshot-vs-in-flight ordering problem to solve because there is no second source: the list is the only registry a client ever sees.
3. The client copy, colliders and sprites — one code path on every machine
ClientRegistry(Runtime.Build, one per machine including the host's own process): subscribes toStructures.OnListChanged; onAddcreates aStructureView— a plain (non-networked)GameObjectatCell.Center(X, Y)with aBoxCollider2Don layerStructuresizedFootprint × 1 u(offset to the footprint's centre),isTrigger = false, static (noRigidbody2D), plus theSpriteRenderer(def's sprite; relic → fallback), thenPhysics2D.SyncTransforms()so the sweep sees it this frame (V3). OnRemove(keyed byValue.InstanceIdonly — NGO leavesIndexunset on that event, V2) it destroys the view.ClientRegistryexposesIStructureRegistry's read half (All,AnyCovers,TryGet) for the ghost's provisional F1 and the advisorystationPresent*— display only; a relic row surfaces there as aStructureInstancewhoseStructureis the reserved sentinelStructureId.Relic(the client never needs the original string — only the host's save table holds it;AnyCoversuses the row'sW × H;StationPresencenever matches it: no def, no tier) (TD C9) (Rule 13 / AC-39b; fence §7: no command'sExecutecan see it —Rulescannot name a Runtime type, and the host's commands readHostContext.Structures, never the copy).- Instant blocking (AC-27): the collider exists the frame the
Addevent fires; ADR-0009's sweep is per rendered frame and casts againstStructure, so the nextSweep.Moveon every machine stops at it (V3). The host's own player sees the wall on the same frame the replicator'sAddfires (host-local list event). - Outcome vs row (ADR-0008 §2 lag):
StructurePlaced(the edge — thunk, reason-string clear,CarryFoldcost) may arrive one tick before the row; presenters act on the outcome, movement on the collider. Nothing readsClientRegistryinside an outcome handler (ADR-0008'soutcome_handler_reads_variable). - Host and client are the same code: the host's
ClientRegistrylistens to its ownBaseNetlist, so wall colliders on the host are created by the identical path — no "host instantiates directly" branch. Ordering on the host, stated (TD C4):HostContext.Structuresgains the row insideExecute; the host's own collider appears whenRegistryReplicatorfires the list event after the drain — a sub-tick window in which the registry leads the colliders. Safe by construction because the two are read from different places and never compared: every host gate reads the registry (never a collider), every sweep reads colliders (never the registry). No code may derive a blocker fromHostContext.Structuresor a gate from a collider — reviewed per command and registered (registry_and_colliders_never_compared); ADR-0012 in particular may not query colliders for enemy pathing.
4. Chests — a registry row and a StorageNet, bound lazily
A storage-category placement produces two replicated things from one command: the registry row (this ADR) and the StorageNet spawned via QueueSpawn(SpawnRequest.SharedStorage(instance, origin, slots)) (ADR-0010) — both deferred effects of the same drain, on two NetworkObjects with no ordering relation. StructureView for a storage row therefore binds lazily: it looks up StorageNet by StructureInstance (a static dictionary StorageNet registers itself into on OnNetworkSpawn) and, if absent, subscribes to a StorageNet.Spawned event; StorageView (ADR-0010) binds the other way for the UI. Demolish: DemolishCommand (gated on ChestNotEmpty) removes the row and QueueDespawns the object in the same drain; a client handles either order (the view's OnDestroy unsubscribes; a StorageNet despawn with no view is a no-op). A client with the chest panel open when the row is removed closes it on the Remove event (TD C5): StructureView's teardown raises StructureRemoved(InstanceId) and PanelManager (ADR-0014) closes any panel bound to that instance; StructureDemolished stays the presenter edge — the close is keyed on the row, never the outcome (§3's rule). PlayMode asserts: open panel → demolish → panel closed, no StorageNet reference survives. ContainerId = "s" + InstanceId (ADR-0010's constraint, accepted) is the one string form both sides derive from the same uint.
5. Outcomes
Outcome (OutcomeRpc, ADR-0008) |
Fields | Consumers |
|---|---|---|
StructurePlaced | uint Instance, ushort StructureIndex, short X, short Y, PlayerKey Placer (≈ 25 B) | presenters (thunk, ghost clear), HUD picker affordability, CarryFold (−StructureDef.Cost for Placer), EC21 not (see §6) |
StructureDemolished | uint Instance, ushort StructureIndex, short X, short Y, PlayerKey Demolisher, bool RefundIsZero (≈ 26 B) | presenters, CarryFold (+100 % of the current StructureDef.Cost for Demolisher, or nothing when RefundIsZero — relics, EC13/14) |
CommandRejected(ClientSeq, ReasonCode) (ADR-0008) | — | the ghost's reason string on a refused commit (F1 gate order = ReasonCode precedence: TooFar → OutOfBounds → Overlap → Occupied → NoWayOut → BadTerrain → SocketExclusion → NotEnoughMaterials; demolish: AlreadyGone, ChestNotEmpty, NoRoomForRefund) |
Both Structure* outcomes name the actor so the fold (ADR-0010) can apply cost/refund from data; the row itself carries no actor (AC-24 — anonymity is a registry fact, not a wire fact; an outcome is an edge that happens to have an author, exactly like SwingLanded).
6. BB Edge Case 21 — trigger and write mode
ADR-0009 §5 decided the write (owner-side CorrectTo, interpolated for observers). This ADR decides the trigger: on the list Add event (the same instant the collider appears — never on the outcome, which can precede the collider by a tick), the local ClientRegistry checks whether the local player's cell is inside the new footprint; if so it calls Rules.Build.FindCorrectionCell(seed, terrain, registry, placeable) — the GDD's reference implementation, pure, reading no player positions — and PlayerMotor.CorrectTo(cell.Center); null (enclosed) is EC22, socially resolved. Only the owner corrects itself (owner rail); remote bodies under a wall are their own owner's problem on their own machine. The GDD's three pins carry over (TD C6): (ii) re-check immediately before the write — the player may have walked clear between the event and the correction; if its cell is no longer covered, no write; (iii) at most one correction in flight, re-evaluated fresh at the post-correction cell (AC-14e) — a second wall landing during the first correction queues one re-check, never a second concurrent write; and FindCorrectionCell's N, E, S, W order is the binding tie-break (AC-14c; fenced Bld_CorrectionOrderIsNESW against the GDD's fixtures).
7. stationPresent* — two contexts, one function
Rules.Build.StationPresence.Exact(tier, asker, registry, defs, stationRange) / .AtLeast(...) — pure over IStructureRegistry + IGameData.Structures. Host: StationPresence : IStationPresenceProvider over HostContext.Structures, evaluated at the commit instant inside CraftCommitCommand.Execute, never cached (Rule 13); asker is ctx.Positions.HostKnown(OwnerClientId) — the same source and the same ≤ 1 RTT + 1 tick staleness as F1's noPlayerOccupancy (ADR-0009 §7). ADR-0008 §7's RangeSlackUnits does not apply (TD C7, ruled): stationRange is a design knob, not an anti-cheat reach gate; widening it would change crafting's reachability rule. Client: the same function over ClientRegistry for greying/hints — display only; the client never skips the host re-sample (AC-39b). Exactly two predicates are exposed (AC-44; fenced by ADR-0003's Schema_*).
8. Fences (EditMode unless noted)
| Fence | Asserts |
|---|---|
Bld_RowIsUnmanaged | StructureNetRow is 14 B blittable, IEquatable, no reference field |
Bld_StructureIndexIsSortedOrdinal | StructureIndexTable = ordinal-sorted Structures.Keys; relic lookup returns 0xFFFF; hash stable |
Bld_RegionFitsShort | the home island's placeable Region bounds fit short (X, Y) — validated at data load (ADR-0006 row), loud reject otherwise (the bagSlots ≤ 14 posture) |
Bld_ListSingleWriter | Structures.Add(/.Remove(/.Clear(/Structures[ appear only in RegistryReplicator and BaseNet.Initialise (raw-text) |
Bld_RegistryWrittenOnlyByCommands | IStructureRegistry.Add/Remove are called only from PlaceStructureCommand.Execute, DemolishCommand.Execute and StructureRegistry.Deserialize (raw-text over Rules + Runtime) |
Bld_NoIndexOnRemove | no handler of NetworkListEvent<StructureNetRow> reads .Index when Type == Remove (raw-text over Runtime: the Remove branch references Value only); StructureView creation is followed by Physics2D.SyncTransforms() (raw-text) |
Bld_CorrectionOrderIsNESW | FindCorrectionCell returns the GDD reference block's answer on its fixtures — N before E before S before W; a fixture with two clear neighbours picks the earlier |
Bld_RegistryAndCollidersNeverCompared | review-checklist item: no method reads both HostContext.Structures (or IStructureRegistry) and a Collider2D/Physics2D query (raw-text over Runtime.Build + Runtime.Enemy) |
Bld_ClientRegistryIsAdvisory | no type in Driftlands.Rules references ClientRegistry (vacuous by assembly — asserted anyway) and no HostContext member is a ClientRegistry (reflection) |
Bld_ViewPerRow (PlayMode, host) | after an Add event a BoxCollider2D exists on layer Structure with the footprint's size at the anchor; after Remove it is gone; a relic row yields a collider of the row's W × H |
Bld_TwoStationPredicates | ADR-0003's AC-44 allow-list (exists) — referenced |
Schema_StructureInstance | ADR-0003's AC-24 allow-list (exists) — the wire row is a different type and is allowed its four fields |
Architecture Diagram
HOST (drain) EVERY MACHINE (host included)
PlaceStructureCommand.Execute: 8 gates over HostContext.Structures/Terrain/Sockets/Positions → Inventory.Remove(cost) → Structures.Add → Persist → Broadcast(StructurePlaced) → [storage] QueueSpawn(SharedStorage)
DemolishCommand.Execute: exists ∧ (not storage ∨ empty) ∧ CanAdd(refund) → Inventory.Add → Structures.Remove → Persist → Broadcast(StructureDemolished) → [storage] QueueDespawn
│ phase 4: OutcomeRpc ─────────────────────────────────────────────────────► presenters (thunk) · CarryFold (cost/refund from data) · HUD
│ after Drain: RegistryReplicator → BaseNet.Structures.Add/Remove(row) ═══ list delta ═══► ClientRegistry: StructureView (BoxCollider2D on Structure + sprite) · EC21 check (owner) · advisory F1 / stationPresent
│ StorageNet spawn/despawn (ADR-0010) ═══ separate object ═══► StructureView ⇄ StorageNet bound lazily by InstanceId
boot: Save.Load → StructureRegistry (relics kept) → BaseNet.Structures filled → StartHost; late joiner: BaseNet scene sync carries the whole base (V1); snapshot has no structures table
Key Interfaces
namespace Driftlands.Rules.Commands // placeholders shipped in HostContextServices.cs get their shape
{
public interface IStructureRegistry { // HostContext.Structures — host truth; ClientRegistry implements the READ half only
IReadOnlyList<StructureInstance> All { get; }
bool AnyCovers(Cell c); // any footprint covers the cell (data for live rows, the stored column for relics)
bool TryGet(uint instanceId, out StructureInstance s);
uint Add(StructureId id, Cell origin); // host only, inside PlaceStructureCommand.Execute; returns the monotonic instanceId
bool Remove(uint instanceId); // host only, inside DemolishCommand.Execute
}
public interface IStationPresenceProvider { // exactly two (BB AC-44)
bool StationPresentExact(StationTier tier, Vector2 asker);
bool StationPresentAtLeast(StationTier tier, Vector2 asker);
}
public interface ITerrainQuery { bool IsBuildable(Cell c); bool InPlaceableRegion(Cell c); }
}
namespace Driftlands.Data { public sealed record StructureInstance(uint InstanceId, StructureId Structure, Cell Position); } // exactly three (AC-24, fenced)
namespace Driftlands.Rules.Build // engine-free (ADR-0003 already assigns F1–F4, snap, FindCorrectionCell here)
{
public readonly record struct Footprint(Cell Origin, int W, int H) { public bool Covers(Cell c); public Cell Center { get; } }
public static class BuildRules { public static PlacementVerdict PlacementLegality(/* ADR-0003 §4's argument list */); public static DemolishVerdict DemolishLegality(/* structure, storage-empty, CanAdd(refund) */); public static Cell? FindCorrectionCell(Cell seed, ITerrainQuery terrain, IReadOnlyList<Footprint> registry, Region placeable); }
public static class StationPresence { public static bool Exact(StationTier t, Vector2 asker, IStructureRegistry r, IReadOnlyDictionary<StructureId, StructureDef> defs, float range); public static bool AtLeast(/* same */); }
public sealed record StructurePlaced(uint Instance, StructureId Structure, Cell Origin, PlayerKey Placer) : IOutcome; // wire: StructureIndex ushort
public sealed record StructureDemolished(uint Instance, StructureId Structure, Cell Origin, PlayerKey Demolisher, bool RefundIsZero) : IOutcome;
public sealed record DemolishCommand(ulong OwnerClientId, uint ClientSeq, PlayerKey Player, uint Instance) : ICommand; // PlaceStructureCommand: ADR-0003 §4
}
namespace Driftlands.Runtime.Build
{
public struct StructureNetRow : IEquatable<StructureNetRow> { public uint InstanceId; public ushort StructureIndex; public short X; public short Y; public byte W; public byte H; } // 14 B; 0xFFFF = relic (renders at W×H)
public sealed class StructureIndexTable { public ushort Of(StructureId id); public StructureId? At(ushort i); public uint Hash { get; } public static StructureIndexTable Build(IGameData data); }
public sealed class StructureRegistry : IStructureRegistry, IReconcilable { /* structures table + instanceId counter; relics kept with their string id */ }
public sealed class BaseNet : NetworkBehaviour { // in-scene, always spawned, server-owned; bulk state off the command channel
public NetworkList<StructureNetRow> Structures = new(writePerm: NetworkVariableWritePermission.Server);
internal void Initialise(IStructureRegistry host, StructureIndexTable idx); // host, before StartHost
}
public sealed class RegistryReplicator { public void AfterDrain(IReadOnlyList<StructureInstance> added, IReadOnlyList<uint> removed); } // the ONLY runtime writer of Structures
public sealed class ClientRegistry : IStructureRegistry /* read half; Add/Remove throw */ { /* OnListChanged → StructureView create/destroy; EC21 check for the local player */ }
public sealed class StructureView : MonoBehaviour { /* BoxCollider2D on Structure + SpriteRenderer; lazy StorageNet binding for storage rows */ }
public sealed class StationPresence : IStationPresenceProvider { /* host: over HostContext.Structures at commit instant; client instance over ClientRegistry for display */ }
}
Alternatives Considered
Alternative B: a NetworkObject per structure
- Description: each wall/station/chest is a spawned prefab with
NetworkVariables for def and position; colliders live on the prefab. - Pros: no client instantiation code; a chest is one object, not a row plus an object.
- Cons: 50–100 spawn messages for a perimeter; each placement is a spawn (heavier than a 12 B delta); the in-scene
NetworkPrefabsListgrows per structure kind;AnyCovers/FindCorrectionCellon clients would iterateNetworkObjects; a late joiner receives 100 spawns instead of one list. - Rejection Reason: user decision 2026-09-11; the registry is a small table and NGO lists exist for small tables.
Alternative C: outcome-driven registry + snapshot
- Rejection Reason: user decision 2026-09-11 — a joiner would have no walls until it applied the document, and the snapshot-vs-in-flight ordering problem row 11 asks about would be real; the list dissolves it.
Alternative D: wall Tilemap + TilemapCollider2D + CompositeCollider2D
- Pros: one merged collider for hundreds of wall cells.
- Cons: every placement rebuilds the composite; stations and chests still need their own colliders; a second representation of "where walls are" beside the registry.
- Rejection Reason: user decision 2026-09-11; a
BoxCollider2Dper row is one collider per structure at EC18's scale. Recorded as the optimisation to revisit if the box count ever measures.
Alternative E: store the relic's footprint in StructureInstance
- Rejection Reason: AC-24's allow-list is a pillar-level fence (anonymity); widening it for relics would reopen it. The chosen form — a save-table column plus two wire bytes — preserves the footprint without touching the type.
Alternative E′: narrow relics to a 1×1 anchor cell
- Cons: the base silhouette shrinks where a def was removed; cells the relic covered become buildable and walkable while its art still reads as solid — a creative change to EC13's "the base silhouette holds".
- Rejection Reason: user decision 2026-09-11 (offered with the column option; the column costs 2 wire bytes and one save column).
Alternative F: EC21 triggered from the StructurePlaced outcome
- Rejection Reason: the outcome can precede the row (and thus the collider) by one tick (ADR-0008 §2); correcting before the collider exists could move the player into it. The list event is the collider's own edge.
Consequences
Positive
- One list, one replicator, one client path for host and clients alike; walls block on the same frame the row lands everywhere.
- The snapshot loses another table; late join for the whole base is the in-scene object's own sync.
- The registry's anonymity stays a schema fact: the wire row has no actor; the outcomes that do are edges.
- Chest binding is order-independent by construction, so the two-object placement never needs an ordering guarantee NGO does not give.
Negative
- A structure is two things on a client (row → view) and, for chests, three (row, view,
StorageNet); the lazy binding is code that must exist. - Every row carries 2 B of footprint the data already knows — redundancy accepted so relics need no def.
StructureIndexmeansstructures.jsonedits that add/remove a def change every client's index table — thedataHashdisconnect is the guard, and it is loud by design.
Risks
- V1 fails (in-scene object list not delivered at scene sync):
BaseNet.Ready : NetworkVariable<bool>set after fill;ClientRegistrybinds on it — one line, same pattern as ADR-0010's closed contingency. - Wall count growth past EC18's assumption:
BoxCollider2Dper row is linear; Alternative D is the recorded swap; the replicator and list are unaffected. - A def removed by a patch: the relic keeps its footprint (column), so nothing about collision or
noOverlapchanges for anyone standing near it; the only visible change is the fallback art. If a save predates the column (none exist before this ADR — the table is new), the loader treats a missing column as1×1and reportsKeptOrphan.
GDD Requirements Addressed
| GDD / spec | Requirement | How This ADR Addresses It |
|---|---|---|
| base-building Rule 6, F1 (TR-bb-004) | one host critical section over the registry | IStructureRegistry.Add inside PlaceStructureCommand.Execute; the mirror follows |
| base-building Rule 8, AC-27 (TR-bb-007, TR-move-003) | a wall's function is collision; blocks the instant placement lands | §3 StructureView collider on Add; ADR-0009's sweep |
| base-building Rule 9, F2, EC13 (TR-bb-005) | demolish; total refund; relics refund zero; storage empty-first | DemolishCommand + StructureDemolished{RefundIsZero}; ChestNotEmpty before QueueDespawn |
| base-building Rule 12 (TR-bb-006) | storage placement instantiates a container | row + StorageNet (ADR-0010), bound lazily (§4) |
| base-building Rule 13, AC-39b, AC-44 (TR-bb-006, TR-craft-003) | stationPresent host-authoritative at commit; client copy advisory; exactly two predicates | §7 |
| base-building AC-24 (TR-bb-002) | StructureInstance exactly three fields | unchanged; the wire row is a separate type; relic footprint not stored (§1) |
| base-building EC15/16 | missing container list is Inventory's; instanceId never reused | ADR-0010 orphan rule; counter persisted, replicated as InstanceId |
| base-building EC21/22 | correction trigger; enclosure social | §6 |
| save Rule 5 stage 6 (TR-save-005) | permanent reconcile, inert relics | StructureRegistry : IReconcilable; relics in the list as 0xFFFF |
| ux build-mode (picker, ghost, reasons, demolish preview) | data the UI needs | ClientRegistry + StructureIndexTable + CommandRejected reasons in F1 order |
Performance Implications
- CPU: replicator O(changes) per drain;
ClientRegistryO(1) per event;AnyCoversO(rows) per ghost frame (≤ ~100) — trivial; one static collider per structure. - Memory: 14 B per row on the wire; one
GameObject+BoxCollider2Dper structure per machine. - Network: 14 B delta per placement/demolish; ≤ ~1.4 KB in the scene-sync message for the whole base.
- Load time:
StructureIndexTableat data load;BaseNetfill beforeStartHost(µs).
Migration Plan
StructureDef/BuildConfigfields +structures.json+RequiredFiles(+ bothTestJsonfixtures) — BaseBuilding Data story (ADR-0006 validation rows already specified).Rules.Build:Footprint,BuildRules(F1 eight conjuncts, F2, snap,FindCorrectionCell),StationPresence— pure, EditMode, mutation-checked (the GDD's ACs).StructureRegistry+structurestable + counter (ADR-0004 stage 6) +IStructureRegistryshape;PlaceStructureCommand/DemolishCommand+ outcomes + codecs (ADR-0008);CarryFoldcases (ADR-0010).StructureIndexTable(+dataHash),StructureNetRow,BaseNet,RegistryReplicator,ClientRegistry,StructureView+Bld_*fences; V1/V3 in PlayMode; lazyStorageNetbinding (V4).- EC21 trigger in
ClientRegistry(with ADR-0009'sCorrectTo);StationPresencehost + client instances. - HUD build picker / placement screens bind to
ClientRegistry/StructureIndexTable(presentation-ui-hud-screens).
Validation Criteria
- EditMode: the §8 fences with companion negatives; F1/F2 pure tests per the GDD's ACs (AC-13–19, 20b, 14b/14c);
StructureIndexTableround-trip + relic0xFFFF; codec round-trips for both outcomes. - PlayMode (host): after a scripted place/demolish sequence,
BaseNet.Structuresequals the registry's rows;StructureViewcolliders match (V3); a storage row binds to itsStorageNetwhether the spawn is processed before or after the row (V4, simulated by firing the events in both orders). - Documented playtest / two standalone builds: V1 (late joiner receives the full base at scene sync and walks into a wall immediately); a wall placed by the host blocks the client on its next frame (AC-27); two ghosts racing one footprint → exactly one commits (AC-20c); EC21 correction slides on observers (ADR-0009 V2).
Related Decisions
- ADR-0008 (rule, outcomes, snapshot,
dataHash), ADR-0009 (Structurelayer, sweep, EC21 write), ADR-0010 (StorageNet,SpawnRequest,ContainerId, index tables, fold), ADR-0002/0003 (commands,HostContext, purity, AC-24/44 fences), ADR-0004 (stage 6), ADR-0006 (structures.json). design/gdd/base-building.md,design/ux/build-mode.md,design/quick-specs/player-movement-2026-08-27.md(Rule 6),crafting-system.md(station gate);architecture.mdrow 11.