DRIFTLANDS co-op survival on drifting tides — studio progress

ADR-0005: Land Lifecycle on Addressables — Per-Template Groups, Preload/Activate/Teardown on Tide Edges, the LandNet Object, and the Per-Template Memory Budget

Status

Accepted

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

Date

2026-08-28

Engine Compatibility

Field Value
EngineUnity 6.3 LTS (6000.3) · Addressables 2.x (com.unity.addressables) · NGO 2.x · C#
DomainCore / Assets (Addressables) — with one Networking touch-point (the LandNet object)
Knowledge RiskMEDIUM — Addressables 2.x async/Awaitable patterns and release discipline are post-cutoff-adjacent (architecture §Engine Knowledge Gap). The NGO half is deliberately shaped so that no addressable prefab is ever an NGO network prefab — the one interaction whose 2.x behaviour is least documented (specialist-verified: NGO 2.x's NetworkPrefabHandler docs warn against relying on its spawn timeout (NetworkConfig.SpawnTimeout — the "~1 s" figure came from a 1.x doc page; the value is irrelevant to this design — review E5, 2026-08-28) for asset readiness; there is no supported primitive for gating a spawn on a client-side async load).
References Consulteddocs/engine-reference/unity/VERSION.md, plugins/addressables.md, breaking-changes.md, deprecated-apis.md, current-best-practices.md, modules/networking.md; live: Addressables 2.0 manual (InitializeAsync, BuildLayoutReport), NGO 2.6 NetworkVariable manual, NGO 2.10 NetworkPrefabHandler; ADR-0001/0002/0003/0004/0006; design/quick-specs/land-dock-undock-2026-08-27.md; design/art/art-bible.md §8.A.5 / §8.B.1–2/6
Post-Cutoff APIs UsedAddressables.InstantiateAsync / ReleaseInstance / Release / LoadResourceLocationsAsync / InitializeAsync (2.x); Awaitable (6.x) for timers and for awaiting AsyncOperationHandle.Task. Specialist-confirmed: NGO 2.x delivers initial NetworkVariable values before OnNetworkSpawn (manual 2.6), so LandNet.OnNetworkSpawn may read its identity synchronously. The same is UNVERIFIED for NetworkList (no 2.x manual page states spawn-payload atomicity for lists) — ADR-0010's NodesReady gate is therefore a required fallback, not optional (architecture review E5, 2026-08-28). Addressables auto-initialises on first API call; InitializeAsync is optional and idempotent (manual 2.0).
Verification Required(1) await handle.Task from an Awaitable method — Unity documents no interop contract between the two (both resume on the main thread next tick, so no hazard is expected); the PlayMode round-trip test exercises await handle.Task as the pattern under test, with handle.Completed → Awaitable.MainThreadAsync as the fallback if it misbehaves. (2) Release: the InstantiateAsync handle is released by exactly one call — Verification decides whether that call is ReleaseInstance(GameObject) or Release(handle) (the manual names ReleaseInstance as the path for instantiated objects); the Addressables Profiler must show the Land_<id> bundle unloaded afterwards. (3) LoadResourceLocationsAsync on an unknown key returns an empty list, not an exception. (4) Addressables init timing vs SubsystemRegistration is undocumented — the catalog check is a Runtime gate after InitializeAsync (§4), not a boot-hook row. (5) Spawning the non-addressable LandNet from NetworkPrefabsList while each peer instantiates an addressable visual locally needs no NGO registration (expected: none). (6) Build layout report: in Addressables 2.0.x the file is Library/com.unity.addressables/buildlayout (extensionless, legacy text) and written only if "Debug Build Layout" is enabled; 2.7+ writes buildlayout.json. The CI gate must pin the installed sub-version, check the setting in, and fail if the file is absent — never pass vacuously. (7) TilemapCollider2D/CompositeCollider2D enable cost at φ is unmeasured — the PlayMode activate test asserts a frame-time budget for the collider-enable step (Validation).

Documents Amended by This ADR

Document Amendment
ADR-0006TemplateDef gains AddressableKey and per-band CandidateCells; stage 7 gains two pure rows (AddressableKey == $"land/{Id}"; CandidateCells[band].Count ≥ NodeCounts[band]). The catalog-presence check is not a stage — see §4.
ADR-0003IKnownLandTemplates is declared in Driftlands.Data (it is a HostContext member; Rules cannot name a Runtime type).
design/art/art-bible.md §8.A.5 / §8.B.1"one shared 'core' atlas … packed at Addressables build time" → "the home atlas is a SpriteAtlas referenced directly by the home scene (always resident, not addressable); the UI atlas stays separate and non-addressable; only land-template atlases are packed by Addressables, one per template."
docs/architecture/architecture.md§3.2 event table + §3.3 dock scenario (SoftCloseDeposit, EvictionExpired, LandNet); API-Boundaries invariant 4 gains its one registered exception (§3 carve-out); QQ-06 resolved.

ADR Dependencies

Field Value
Depends OnADR-0001 (PreloadDue/DockStarted/SoftClose on the Authority clock; NextBoundary(), TemplateFor, VariationSeed; TideClockRole.Presentation; this ADR adds the narrow IPreloadSchedule interface TideClock implements in both roles), ADR-0002 (drain step 3 teardown release — consumed here; the tide-edge carve-out below is the one named exception to its one-channel rule), ADR-0003 (LandInstance in Data; LandLifecycle in Runtime, layout() in Rules), ADR-0004 (KnownLandTemplates permanent-class, stage-4 union), ADR-0006 (TemplateDef, manifest.startingTemplates, stage 7 — amended, see above)
EnablesADR-0010 (node replication: NetworkList<NodeState> lives on LandNet; ADR-0010 must guarantee the list is fully populated before Spawn() — if NGO 2.x cannot deliver initial NetworkList contents atomically with the spawn, ADR-0010 adds a NodesReady NetworkVariable gate and client LandActivated waits for it), ADR-0012 (spawn contract called from host LandActivated), ADR-0013 (LandTornDown ends the held violet), ADR-0009 (SoftCloseDeposit and EvictionExpired are host→owner teleports ADR-0009 must provide), ADR-0008 (late-join: joiner sees LandNet spawned, loads visuals with no preload window; KnownLandTemplates replication is one ADR-0008 row)
BlocksLand-template asset pipeline story (group/label naming); first LandLifecycle round-trip story; Harvesting's node-instance story
Ordering NoteArchitecture Required-ADR row 5 = ADR-0005 (numbered by row). Written after ADR-0006 because TemplateDef and the stage it amends had to exist first. Accept after ADR-0001/0002/0003/0006.

Context

Problem Statement

The tide is a pure function that decides what docks and when; nothing yet says how a land comes to physically exist on every machine, how it stops existing, and how that is kept inside 2 GB. The land-dock-undock quick-spec fixes the semantics and hands three things to an ADR: the Addressables group/label scheme, the per-template MB budget's owner and number (TR-ldu-007, TD-C4), and registry replication. ADR-0002 hands over a fourth: what "teardown release at drain step 3" means to the thing that despawns. ADR-0003 fixed LandInstance but not who produces it or when. This ADR closes all four and answers the question none of them asked — what is the land's network identity — because clients need one authoritative "gameplay exists" edge and the tide events fire on the host clock only.

Constraints

Requirements

Decision

Alternative A: one local Addressables group per land template (visuals only), instantiated offshore as cosmetic at the preload instant, activated at φ by the host spawning a small non-addressable LandNet NetworkObject that is every peer's authoritative "gameplay exists" edge, and released at φ+W+EvictionGrace once drain step 3 has released the socket; LandLifecycle (Runtime) orchestrates and owns a 64 MB per-template budget enforced in CI from the Addressables build layout.

1. What is in the addressable, and what is not

Concern Lives in Why
Tilemap, TilemapCollider2D, SpriteAtlas, prop prefabs, scatter sprites, ambience clipAddressable LandVisualPrefab — one per templateThe only thing that loads and unloads
Node counts per band, spawn points, socket layout, traversalProfile, per-band CandidateCells, AddressableKeyTemplateDef JSON (Driftlands.Data, ADR-0006)layout() is pure over plain data; a land's gameplay shape never depends on an asset load
Node state (swingsRemaining)LandNet (NetworkList<NodeState>, ADR-0010) — written by the host before Spawn() so the spawn message carries itHost-rail state
EnemiesEnemy NetworkObjects (ADR-0012)Host-only simulation
Node/scatter visualsInstantiated locally from LandInstance on every peerSame seed → same placement

Walkability is the prefab's TilemapCollider2D — local on every machine, identical because the asset is identical; movement is owner-authoritative so a client-local collider is right. An EditMode asset test asserts every CandidateCell and SpawnPoint lies on a walkable tile of its prefab. CandidateCells authoring: cells are exported from a designated Candidates_<band> Tilemap layer in the template prefab by an editor tool (owner: tools-programmer, first template story) — the same layer the walkability test reads; never hand-typed. CandidateCells order is load-bearing for determinism: the reader preserves authored array order (ADR-0006 explicit reads); a set/dictionary must never back this member.

2. Groups, labels, keys

3. Lifecycle — the four edges

The tide-edge carve-out (named, bounded). LandNet spawn/despawn and IKnownLandTemplates.Append are host-rail mutations that do not ride HostCommitQueue. An ICommand models a player intent with an owner and a client sequence; a tide edge has neither, and the queue's arrival-order and soft-close-drop semantics are meaningless for it. Bound: exactly these operations, all driven by ITideClock events on the Authority clock, host-only, reading no player state. Ordering: they run inside the tide adapter's Advance, which ADR-0002 step 1 already places before that tick's drain — a φ activation is visible to every command drained in the same tick. A third tide-driven host-rail mutation requires an amendment. Registered as tide_edge_carve_out; architecture invariant 4 gains this as its one exception.

The presentation-clock carve-out (named, type-checked). ADR-0001's Presentation clock throws on any gameplay subscriber. Client preload must nevertheless start before φ, and an asset load is not a gameplay decision. Mechanism: TideClock implements a second, narrow interface in both roles —

public interface IPreloadSchedule { long NextPreloadInstant(int socket); TemplateId NextTemplate(int socket); long NextEvictionInstant(int socket); }  // Driftlands.Tide

int socket, not SocketId; NextEvictionInstant on the pull surface (2026-09-07, review U1 / #13). SocketId is a Driftlands.Data type and Driftlands.Tide references nothing, so the shape as first written could not compile — the same exception as ITideClock (review F2); callers unwrap with SocketId.Value. NextEvictionInstant (= φ + W + EvictionGrace, an instant, never raw t) joins the interface because LandLifecycle's teardown timer needs it and may neither re-derive it (ADR-0001) nor reach the concrete TideClock through this seam; it shipped on TideClock (foundation-tide-scheduler story-009) and a follow-up story moves it onto the interface.

— which LandLifecycle.SchedulePreload reads by pull. LandLifecycle never subscribes to a Presentation clock's PreloadDue/DockStarted/SoftClose, and nothing reached through IPreloadSchedule may enable colliders, raise LandActivated, spawn, or gate a command. The ITideClock gameplay-subscriber guard is unchanged. Registry stance presentation_clock_driving_gameplay is amended to admit exactly IPreloadSchedule.

LandLifecycle : MonoBehaviour (Runtime, one instance per machine, injected — never a static Instance) holds one LandSlot per socket:

LandSlot { SocketId; TemplateId?; long CycleIndex; AsyncOperationHandle<GameObject> Visual; GameObject VisualRoot; LandNet Net;
           LandInstance Instance; bool DrainReleased; bool GraceExpired; SlotState State }
SlotState: Empty → Preloading → Arriving → Active → SoftClosed → TearingDown → Empty
Edge Instant Host Client
PreloadNextBoundary() − PreloadLeadTimePreloadDue event (Authority clock)IPreloadSchedule pull timer (carve-out above)
actionVisual = Addressables.InstantiateAsync(key, offshorePose); await; colliders disabled, tint driver attached, drift-in tween toward the socket. State Arriving. Not enterable (tide Rule 8). Retry on Failed with backoff until φ − 30 s (§4).same
Activateφon DockStarted(socket), in this order: (1) Instance = LandContent.Build(def, VariationSeed(socket, cycle), socket, cycle, epoch); (2) Harvesting.SeedNodeState(Instance) — host writes swingsRemaining incl. live HarvestDiffs into the list backing the not-yet-spawned LandNet; (3) spawn LandNet(socket, template, cycle, epoch); (4) enable colliders (tween snapped to the socket pose); (5) IKnownLandTemplates.Append(template); (6) raise LandActivated(Instance, VisualRoot) → Harvesting node views, IEnemySpawner.Spawn(template, seed, Instance.SpawnPoints, ctx).on LandNet.OnNetworkSpawn (identity NetworkVariables already delivered): compute the same Instance locally (pure); if the visual is still loading, await it; enable colliders; raise LandActivated → node views from the replicated list. Gameplay existence on a client is the LandNet spawn, never its own clock.
Soft-closeφ+WSoftClose(socket) → State SoftClosed; LandNet.SoftClosed = true; raise SoftCloseDeposit(socket) → host deposits every player whose host-known position is on this land at depositPoint (tide Rule 9 — the ordinary, tone-safe deposit; EC8's mid-transition case resolves from the same snapshot; mechanism ADR-0009); drain step 3 later sets DrainReleased = true; start the grace timer.LandNet.SoftClosed flips → State SoftClosed; violet held at 1.0 (tide F7/B2).
Teardownφ+W+EvictionGraceGraceExpired = true. Teardown runs when DrainReleased && GraceExpired (either order — a stalled drain can never race the deposit, harvesting AC-31). Sequence: raise EvictionExpired(socket) → EC11 backstop: hard-relocate anyone who returned or was never successfully moved (not the primary deposit); despawn LandNet; raise LandTornDown(socket) (tint stops, ADR-0013); release the slot (§5); Empty.on LandNet.OnNetworkDespawn: raise LandTornDown, release the slot, Empty.

Release is slot-driven, not solely despawn-driven. LandLifecycle releases a slot's handle on any of: LandNet despawn; a preload whose docking is superseded ((TemplateId, CycleIndex) no longer matches IPreloadSchedule/TemplateFor at the next boundary — the "preloaded but never activated" case); session end (StopHost/disconnect/OnDestroy → ReleaseAll() over every slot). A slot never reaches Empty without its release having run.

Invariants: (a) LandNet is gameplay existence — spawned at φ, despawned at grace expiry; every consumer keys off it or the events it drives, never off a clock. (b) On any machine that completed preload, the φ edge costs colliders-on + LandNet spawn and does no I/O; a client that has not (slow disk, late join) pays the load at activation and its LandActivated fires late — the honest degradation in §4. (c) The four identity variables on LandNet are set once by the host before Spawn() and never mutated; only SoftClosed changes during its life.

Preload budget in time: PreloadLeadTime 600 s vs a local LZ4 bundle ≤ 64 MB — three orders of magnitude of slack; no hitch at φ is the design, not a hope.

4. Failure behaviour — fail loudly, never an empty socket

5. Memory budget — owner and number (TR-ldu-007, TD-C4)

6. KnownLandTemplates

IKnownLandTemplates : ISaveable is declared in Driftlands.Data (a HostContext member — Rules cannot name a Runtime type; ADR-0003 amended), implemented by LandContent (Core, owner), with Append called by LandLifecycle at φ on the host — a clean one-way, idempotent call from the only module that knows when φ happened. Persist: permanent class, stage-4 union with manifest.startingTemplates. Removal: no API exists (schema-negative). Replication is ADR-0008's row; this ADR fixes only the authority (host appends, clients read) and requires a single mechanism — a live mirror makes a snapshot row redundant, and both is a drift source.

Architecture Diagram

 host clock (Authority)                              client clock (Presentation)
   PreloadDue ─────────────────────────┐              IPreloadSchedule.NextPreloadInstant (pull) ──┐
   InstantiateAsync("land/<id>") offshore, colliders OFF, drift-in            (same)
        │ φ: DockStarted                                                       │
   Build(Instance) → SeedNodeState → spawn LandNet ═══(NGO)═══► LandNet.OnNetworkSpawn → Build(Instance)
   colliders ON → Append → LandActivated ──► Harvesting, IEnemySpawner        colliders ON → LandActivated → node views
        │ φ+W: SoftClose → SoftClosedNV=true → SoftCloseDeposit (ADR-0009)     SoftClosed → violet held
   drain step 3 → DrainReleased      grace timer → GraceExpired
        │ both: EvictionExpired (EC11 backstop) → despawn LandNet ═══════► OnNetworkDespawn
   LandTornDown → release slot                                                 LandTornDown → release slot

Key Interfaces

namespace Driftlands.Tide
{
    /// The ONLY Presentation-role surface a non-tint/HUD consumer may read; asset scheduling only (ADR-0001 amended).
    public interface IPreloadSchedule { long NextPreloadInstant(int socket); TemplateId NextTemplate(int socket); long NextEvictionInstant(int socket); }
}

namespace Driftlands.Data
{
    // ADR-0006 TemplateDef — AMENDED (two added members)
    public sealed record TemplateDef(
        TemplateId Id,
        string AddressableKey,                                        // == $"land/{Id}" — stage 7, pure
        IReadOnlyDictionary<Band, int> NodeCounts,
        IReadOnlyDictionary<Band, IReadOnlyList<Cell>> CandidateCells, // NEW — authored order is load-bearing; Count ≥ NodeCounts[band] (stage 7)
        IReadOnlyList<SpawnPoint> SpawnPoints,
        SocketLayout Sockets,
        TraversalProfile Traversal);

    /// Declared here so HostContext (Rules) can name it; implemented by LandContent (Runtime); Append called by LandLifecycle at φ on the host.
    public interface IKnownLandTemplates : ISaveable
    {
        bool Contains(TemplateId t);
        void Append(TemplateId t);                                    // idempotent; no Remove exists
        IReadOnlyCollection<TemplateId> All { get; }
    }
}

namespace Driftlands.Rules.Land
{
    public static class Layout
    {
        /// Pure. Same (def, seed, socket, cycle, epoch) → byte-identical LandInstance everywhere (TR-ldu-002).
        /// Picks NodeCounts[band] cells from CandidateCells[band] by seeded Fisher–Yates over a splitmix stream from variationSeed;
        /// scatter likewise; SpawnPoints and Traversal copied verbatim. All generated members are Cell/int/ulong (ADR-0003 C9 intact).
        public static LandInstance Build(TemplateDef def, ulong variationSeed, SocketId socket, long cycleIndex, int epochIndex);
    }
}

namespace Driftlands.Runtime.Land
{
    /// Runtime-only (carries GameObject) — never a HostContext member.
    public interface ILandLifecycle
    {
        event Action<LandInstance, GameObject> LandActivated;   // each machine, at its own activation instant
        event Action<SocketId>                 SoftCloseDeposit; // host only, at φ+W — tide Rule 9 deposit (ADR-0009 performs it)
        event Action<SocketId>                 EvictionExpired;  // host only, at grace expiry — EC11 backstop
        event Action<SocketId>                 LandTornDown;     // after LandNet despawn, before release
        SlotState StateOf(SocketId socket);
        LandInstance? InstanceOf(SocketId socket);
    }

    /// The land's network identity. Non-addressable prefab in NetworkPrefabsList. Spawned at φ, despawned at grace expiry.
    /// Resolves ILandLifecycle by injection (scene-registered instance), never a static.
    public sealed class LandNet : NetworkBehaviour
    {
        public NetworkVariable<int>   Socket, Template, EpochIndex;   // write-once by the host before Spawn()
        public NetworkVariable<long>  CycleIndex;                     // write-once
        public NetworkVariable<bool>  SoftClosed;                     // the only mutable member
        // ADR-0010 adds: NetworkList<NodeState> Nodes — populated before Spawn()
        public override void OnNetworkSpawn()   => _lifecycle.OnLandNetSpawned(this);
        public override void OnNetworkDespawn() => _lifecycle.OnLandNetDespawned(this);
    }
}

Data-flow classification (architecture §3.2): tide → LandLifecycle is a host C# event / client pull via IPreloadSchedule; LandNet spawn is NGO shared state; LandActivated/LandTornDown local C# events; Addressables loads are main-thread async — no Driftlands code crosses a thread boundary.

Alternatives Considered

Alternative B: Each template as an additive Addressable scene

Alternative C: All templates always resident — no Addressables at MVP

Alternative D: The addressable land prefab is itself the NetworkObject

Alternative E: No NetworkObject for the land

Consequences

Positive

Negative

Risks

GDD Requirements Addressed

GDD System Requirement How This ADR Addresses It
land-dock-undockRule 1: tide-edge subscription; preload at NextBoundary − PreloadLeadTime; gameplay at φ; unload at +EvictionGrace never earlier§3; teardown = DrainReleased && GraceExpired
land-dock-undockRule 2: layout() pure, byte-identicalRules.Land.Layout.Build over ordered CandidateCells
land-dock-undockRule 3: KnownLandTemplates at first φ, seeded, permanent, union§6; Append in the DockStarted handler (tide-edge carve-out); ADR-0004 stage 4
land-dock-undockRule 4 sole-source warningnot this ADR — discharged by ADR-0006 stage 7; noted so /architecture-review records no LDU gap
land-dock-undockRule 5: enemy instantiation routes through hereIEnemySpawner.Spawn from host LandActivated
land-dock-undockRule 6: MVP "changed on return" = variation, not revisionno mechanism needed — VariationSeed per cycle delivers it
land-dock-undockRule 8: socket layout; Tier-1 3–4-socket re-check (BB OQ2)TemplateDef.Sockets (stage 7); re-check owned in "Deferred" below
land-dock-undockRule 9 / TR-ldu-007: memory discipline, budget owner + number§5
land-dock-undockINTEGRATION: round-trip; teardown never races the deposit§3; SoftCloseDeposit at φ+W precedes LandTornDown
tide-schedulerRule 8 arrival cosmetic; Rule 9 deposit at φ+W; EC11 backstop; F6 no polling; EC9 client evaluation presentation-onlycolliders off until φ; SoftCloseDeposit; EvictionExpired; timers at NextBoundary(); IPreloadSchedule carve-out + LandNet
harvestingEC6/AC-31 teardown after in-flight resolution; AC-40 absent-node diff droppedDrainReleased; LandInstance.Nodes
enemy-aiRule 7 spawn at activation, ephemeralhost LandActivated; nothing of the land saved but KnownLandTemplates
craftingEra gate reads KnownLandTemplates; EC17/18Contains; no removal API
base-buildingSocket layout / depositPoint (AC-63b)TemplateDef.Sockets; deposit target
art bible §8.A.5 / §8.B.1–2/6Atlas classes, caps, compression, import settings§2 (amended wording); §5 three-case import gate
netcode-sessionRule 2 registries host-rail; Rule 4 Known* in late-join§6 authority; representation → ADR-0008, single mechanism

Deferred, with owners

Item Owner Trigger
Tier-1 3–4-socket layout + socketNoBuildRadius re-check (LDU Rule 8, BB OQ2)LandContent / base-buildingfirst Tier-1 multi-socket template
CandidateCells export tooltools-programmerfirst real template story
Remote/DLC Addressables groupnew ADR (no Required-ADR row reserved)if ever
KnownLandTemplates replication representationADR-0008playtest
NetworkList<NodeState>-before-spawn guaranteeADR-0010node replication ADR

Performance Implications

Migration Plan

No production code. (1) ADR-0006 amendment: TemplateDef members + two pure stage-7 rows; IKnownLandTemplates moved to Data (ADR-0003 note); (2) IPreloadSchedule on TideClock (ADR-0001 note); (3) Rules.Land.Layout.Build + purity tests; (4) Addressables settings: Land_<id> groups, land label, local paths, Debug Build Layout on, sub-version pinned; CI layout + import-settings tests; (5) LandCatalogCheck Runtime gate; (6) LandNet prefab + LandLifecycle slot machine + ReleaseAll, PlayMode round-trip; (7) KnownLandTemplates registered with Save; (8) art-bible §8.A.5/§8.B.1 wording edit in the same change; (9) consumers as Harvesting/EnemyAI/ADR-0009 stories land.

Validation Criteria

Related Decisions

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

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

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

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