Control Manifest — Driftlands
Manifest Version: 2026-09-11
Engine: Unity 6.3 LTS (6000.3) · installed editor6000.3.18f1· URP 17.3.0 2D (Render Graph) · Shader Graph 17.3.0 · Input System 1.20.0 · Addressables 2.9.1 · Newtonsoft 3.2.2 · UI Toolkit · Netcode for GameObjects 2.13.2 · Unity Transport 2.7.3 · Multiplayer Services SDK 2.2.1 (Relay/Authentication/Core) · Multiplayer Tools 2.2.9 — pinned by ADR-0008, verified against the package source in the Gate 0 kit; not yet inUnityProject/Packages/manifest.json(NetcodeSession story 001 adds them and commitspackages-lock.json)
Sources: all 15 Accepted ADRs (0001–0015) as amended 2026-09-11 (ADR-0008–0012 each amended ADR-0001/0002/0003/0004/0005/0006/0015 in their own commits) ·.claude/docs/technical-preferences.md·docs/registry/architecture.yaml(69 active forbidden patterns, 8 budget entries = the 60 fps / 16.6 ms frame row + 7 per-system slices) ·docs/engine-reference/unity/{VERSION,breaking-changes,deprecated-apis,current-best-practices}.md·production/playtests/gate-0-2026-09-11/(Gate 0 GO)
Generated by:/create-control-manifest
Technical Director Review (TD-MANIFEST): CONCERNS → REVISED 2026-09-11 — C1–C6 applied; the three ADR sentences that contradicted their own ADR (ADR-0008 §4 outcome cap, ADR-0010CarryFold.Applyarity, ADR-0012EnemySpawnerteardown trigger) were corrected as editorial amendments in the same commit
What this is. A flat rules sheet. ADRs explain why; this tells you what, in a form you can check a diff against. If a rule here and an ADR disagree, the ADR wins — and the disagreement is a bug in this file, so report it.
Stories embed the Manifest Version above. /story-done compares it and flags staleness, so regenerate this whenever an ADR is accepted or amended. Every story written before 2026-09-11 (versions 2026-09-02/03/07) is now stale against this file; the new rules are the NetcodeSession, PlayerMovement, Inventory replication, Structure registry and Enemy simulation blocks plus the amended Host-commit / Combat / Save / GameData / Land rows marked (2026-09-11).
✅ Gate 0 PASSED — confirmed GO 2026-09-11
The two-machine friend playtest ran on 2026-09-11 over Unity Relay (Jeroen host, Eric client, RTT ≈ 150–280 ms): own movement and dash instant, hits landed where seen, zero client warps; verdict GO (production/playtests/gate-0-2026-09-11/README.md, production/gate-checks/gate-0-provisional-pass-2026-09-11.md). ADR-0008–0012 are Accepted without qualification and every epic that routed through them is unblocked (production/epics/index.md). The netcode model (owner-authoritative movement, host-authoritative everything else, local-player prediction only, one ordered channel, host tick) is unchanged and binding. The one playtest observation — occasional remote stutter at that RTT — is a register item (interpolation tuning, ADR-0009 §6: offset 1→2, then SmoothDampening, players win arbitration; never extrapolation), not a blocker.
GLOBAL — applies to every layer
Naming (technical-preferences)
| Thing | Convention |
|---|---|
| Classes | PascalCase — PlayerController |
| Public fields/properties | PascalCase — MoveSpeed |
| Private fields | _camelCase — _moveSpeed |
| Methods | PascalCase — TakeDamage() |
| Events | PascalCase, past tense — HealthChanged, LandDocked |
| Files | PascalCase matching the class |
| Scenes/Prefabs | PascalCase — HomeIsland.unity, LandTemplate_Marsh.prefab |
| Constants | PascalCase or UPPER_SNAKE_CASE for true invariants |
| Tests | test_[system]_[scenario]_[expected] (.claude/rules/test-standards.md) |
| Wire rows / net behaviours | NetRow for an unmanaged NetworkList element (StackNetRow, NodeNetRow, StructureNetRow), Net for the NetworkBehaviour that owns replicated fields (PlayerNet, EnemyNet, StorageNet, BaseNet, SessionNet) (ADR-0008–0012) |
Global budgets
- 60 fps / 16.6 ms frame budget. The host is also a player — nothing gets a free pass for being "the server".
- < 100 draw calls.
- 2 GB memory ceiling.
- Registered per-system slices total 1.7 ms of 16.6: tide 0.1 · host-commit-drain 1.0 · save-persistence 0.2 · land-lifecycle 0.0 · netcode-session 0.2 (2026-09-11) · ui-panels 0.0 · ui-worldspace 0.2. ADR-0012 additionally budgets
EnemyAI.Tick≤ 0.3 ms per tick for 20 enemies (Feature guardrails) — inside the drain-adjacent tick work, not a registered slice yet. - ⚠️ Combat (ADR-0015) still registers no per-frame budget — a known hole. It is plausibly the largest per-frame consumer, so a slice that hits 60 fps tells you nothing about headroom until this is closed.
- Network (ADR-0008 §8): ≈ 15 KB/s down / 2 KB/s up per client at 2 players + 8 enemies; guardrail ≤ 20 KB/s down / 5 KB/s up.
Forbidden project-wide
- No rollback / full reconciliation netcode. The model is owner-authoritative movement + host-authoritative damage, prediction for the local player only. Never design a mechanic needing server-side rewind or remote-player prediction.
- No dedicated servers, no server-side simulation. Host-and-play only.
- No host migration. Host leaves → session ends.
- No offline base simulation. Only the deterministic tide schedule advances while nobody plays.
- No tide-compensation mechanics — grace periods, partial-credit harvesting, catch-up boosts are banned by Pillar 1. Recurrence is the only legal mitigation.
- No legacy Input Manager (
UnityEngine.Input) in production code (legacy_input_manager). - No NGO 1.x APIs — NGO 2.x only.
NetworkTransform.Updateoverride is gone; useOnUpdate(non-authority must callbase.OnUpdate()).RequireOwnershipon[Rpc]is[Obsolete]in 2.13.2 — useInvokePermission = RpcInvokePermission.{Everyone|Owner}; the fenceNet_NoObsoleteRpcFlagsfails the build on it (ADR-0008 §9).NetworkObject.IsSceneObjectis deprecated — useInScenePlaced. - No distributed authority —
NetworkConfig.NetworkTopology = ClientServer, always (distributed_authority_topology, ADR-0008 §1). - No relevancy filtering —
NetworkObject.CheckObjectVisibilityis never assigned; every client observes every object (relevancy_filtering, ADR-0008 §8). - No owner-written
NetworkVariable/NetworkListanywhere in Runtime/Presentation — every replicated field isNetworkVariableWritePermission.Server; an owner→host fact is anICommandon the channel (owner_written_network_variable,owner_to_host_fact_outside_channel; fenceNet_NoOwnerWrittenNetworkVariables). - No reading a
NetworkVariable.Value(orClientRegistry) inside anOutcomeRpchandler expecting it to already reflect that outcome — state replicates one tick after the outcome; act on the outcome's own payload (outcome_handler_reads_variable, ADR-0008 §2; review-checklist fenceNet_NoVariableReadsInOutcomeHandlers). - No
[SerializeField]on anything but a field — compile-time error on 6.3. - No deprecated 6.3 APIs:
AccessibilityNode.selected→.invoked;AccessibilityRoleis a standard enum, not flags;SetupRenderPasses→AddRenderPasses; URP Compatibility Mode is removed.
Pinned toolchain facts (verified by compiling, 2026-09-03)
Unity 6000.3.18f1 compiles C# 9.0 against .NET Standard 2.1. Three things ADR text assumes that the compiler refuses:
- No
record struct— C# 10 (CS8773). Usereadonly struct+IEquatable<T>.recordclasses are fine. (ADR-0009–0012 Key Interfaces are written withreadonly record structfor brevity — implement them asreadonly struct+IEquatable<T>, exactly as ADR-0003 §3's shapes were.) - Records need the
IsExternalInitpolyfill — oneinternal static class IsExternalInit {}inSystem.Runtime.CompilerServicesper assembly that declares a record (CS0518 otherwise). Already inRulesandTests.EditMode; copy it into any new assembly that adds a record. - No
IReadOnlySet<T>— .NET 5+. UseIReadOnlyCollection<T>.
If an ADR names a shape, compile it before accepting the ADR. ADR-0003 §3 carried all three of the above unnoticed through review, acceptance, and a producer gate.
Pinned NGO 2.13.2 facts (read from the package source 2026-09-11 — (src) in ADR-0008–0012)
These are facts about the installed package, not recommendations. Every one of them changed a design decision; code that assumes the training-data behaviour is wrong.
- **A
NetworkVariable/NetworkListwritten insideDrain()replicates at the next tick, not the same one.**NetworkBehaviourUpdatersubscribes toNetworkTickSystem.Tickbefore any user code and sends already-dirty state first;Tickis a plainevent Actionin registration order. AnOutcomeRpcflushed from drain phase 4 rides the same tick's batch, so a client can receiveSwingLanded/HarvestResolved/StructurePlacedone tick before the variable/row the same drain produced. Command latency: RTT + ≤ 1 tick for outcomes, RTT + ≤ 2 ticks for state (ADR-0008 §2; amends ADR-0002's same-tick claim). NetworkList<T>requires exactlywhere T : unmanaged, IEquatable<T>—INetworkSerializeByMemcpyis inert for lists. BackingNativeListgrows on demand. Indexer set → oneValueevent + one delta (ADR-0010 V3, source-confirmed); that a client'sOnListChangedseesIndex == iis V2, still to be verified in PlayMode.NetworkList.Remove(T)never assigns the event'sIndex— it is0/meaningless on aRemoveevent and later elements shift. Key onValueonly (list_index_on_remove, ADR-0011 V2).- Pre-spawn
NetworkVariable/NetworkListcontents ride the spawn message in full (WriteFieldSynchronization → WriteField) and are readable inOnNetworkSpawn; in-scene objects deliver theirs to a late joiner atSceneEventType.Synchronizeprovided the list was filled beforeStartHost(CHANGELOG #3177) (ADR-0010 V1, ADR-0011 V1). ADR-0005'sNodesReadycontingency is closed. - A
byte[]field in anINetworkSerializableallocates a fresh array per receive (FastBufferReader.ReadValueSafe(out byte[])) — no pooling is possible on the read side. Wire envelopes areunsafestructs withfixed byte Payload[N](ADR-0008 §4). NetworkTransform.Teleport()throws when called from a non-authoritative context. Every host-initiated reposition of an owner-authoritative body is therefore a message the owner honours (PlayerSnapped), never a host-side call (ADR-0009 §4).NetworkTransform.InterpolationBufferTickOffsetis astatic int— global to everyNetworkTransform, players and enemies alike. Effective buffer =max(1, TickLatency + offset)whereTickLatencyis RTT-adaptive at runtime (floor 2 once measured).LegacyLerpignoresTickLatency; useLerp(ADR-0009 §6).- **
PositionThresholdgates sending only**; there is no receive-side distance threshold in the 2.13 interpolator — a 1-cell authoritative write slides on observers (ADR-0009 V2, QQ-02). - **
NetworkVariableUpdateTraits.MinSecondsBetweenUpdatesgates sends, not writes** — last value wins, host writes every frame are free; set viaSetUpdateTraits(...)after construction, never a constructor argument (ADR-0008 §8, ADR-0012 V2). NetworkObject.Spawn()/Despawn(true)send one message per object per client — NGO does not merge spawns (the transport may pack them);Despawnsends the destroy immediately (DeferredDespawnTickis a separate opt-in this project does not use) (ADR-0012 V1, §2).NetworkObject.DontDestroyWithOwnerdefaults tofalse— a disconnecting client's player body despawns; the player prefab keeps the default (ADR-0008 §6).- Reliable RPC ordering is per
NetworkObject, across all its methods, not globally — the fact behind "one channel = total order" and behind giving the snapshot and the registry their own objects (ADR-0008 §3). UnityTransport.MaxPayloadSizedefaults to 6 144 B; snapshot chunks are 4 096 B (ADR-0008 §6).EnsureNetworkVariableLengthSafetystaysfalse— every replicated struct is fixed-size.SetStatehas a native owner-push path (SetStateClientRpc,SendTo.Owner) — considered and not used (ADR-0009 Alt H);SetState(andTeleport(appear in exactly one method (PlayerMotor.OnSnapped).- ⚠️ Still OPEN: (a) in which player-loop stage NGO applies incoming RPCs and
NetworkTransformstate relative toTick— undocumented; ADR-0008 V1 probe in NetcodeSession story 004, result recorded as an ADR-0008 amendment;RangeSlackUnitsabsorbs one extra tick either way. (b) whetherNetworkTransformState's position accessor is reachable fromDriftlands.Runtimeand in world space — ADR-0009 V1; fallback documented there.
The 69 registered forbidden patterns
docs/registry/architecture.yaml is canonical. Each name below is greppable; the layer sections expand the ones you are most likely to trip.
unity_refs_in_tide_assembly · static_cache_in_pure_assembly · raw_tide_time_on_consumer_surface · caching_evaluate_across_ticks · iterating_elapsed_cycles · second_authority_clock · presentation_clock_driving_gameplay · mpb_on_driftlayer_sprite · exposed_shader_global_read · non_render_graph_pass · baked_tide_state_variant · ui_event_system_present · host_rail_rpc_outside_channel · execute_on_rpc_receipt · async_or_reentrant_execute · spawn_or_send_inside_execute · save_io_inside_drain · upward_assembly_reference · code_outside_asmdef · conversion_outside_unityinterop · float_output_from_client_evaluated_rules · formula_in_runtime_assembly · reflection_serialize_gameplay_types · dictionary_iteration_into_save · second_droppable_table · unknown_key_preservation · quit_without_flush · silent_default_on_missing_key · hardcoded_gameplay_value · cross_stage_forward_reference · editing_streaming_assets_in_a_build · second_copy_of_source_template_mapping · addressable_network_prefab · land_load_outside_lifecycle · addressables_call_in_validation · vacuous_layout_gate · activation_from_local_clock · legacy_input_manager · input_system_outside_adapter · per_consumer_edge_detection · input_edges_in_fixed_update · scheme_gating_gameplay · aim_persistence_toggle · owner_to_host_fact_outside_channel · multiple_tick_subscribers · client_stamped_intent_tick · clientid_as_row_key · burst_in_rules · movement_speed_modifier_for_downed · distributed_authority_topology · relevancy_filtering · bulk_payload_on_command_channel · owner_written_network_variable · outcome_handler_reads_variable · byte_array_on_command_wire · rigidbody_driven_motion · teleport_outside_snap_path · collision_callbacks_in_movement · host_known_is_not_continuous · carry_on_wire · storage_rows_written_outside_replicator · bag_mutation_without_outcome · registry_and_colliders_never_compared · list_index_on_remove · client_registry_gating_a_commit · client_side_enemy_ai · enemy_save_table · random_in_enemy_rules · collider_queries_in_enemy_runtime
(bold = added 2026-09-11 by ADR-0008–0012)
FOUNDATION LAYER
TideScheduler · SavePersistence · GameData · LandLifecycle · Input · NetcodeSession
Required
Assemblies (ADR-0003)
- The graph is fixed.
Driftlands.Tide,.Data,.RulessetnoEngineReferences: true;.Runtime,.Presentationdo not. Rulesreferences onlyTideandData;Datareferences onlyTide(amended 2026-09-03 — ADR-0006/0005 place Tide types inside Data; the edge is downward to the reference-free root). Runtime never references Presentation.- All five asmdefs set
autoReferenced: false— load-bearing for the no-code-outside-asmdefs fence, not cosmetic. - No production asmdef references
Driftlands.Editor(Tide, Data, Rules, Runtime, Presentation) — a stated fence, asserted byAsm_NoUpwardReferences; test assemblies may (EditMode does; PlayMode reaches editor setup by reflection). Five production + two test + one Editor-only assembly (ADR-0003, amended 2026-09-07). - (2026-09-11)
Driftlands.Runtime.asmdefgainsUnity.Netcode.Runtime,Unity.Networking.Transport,Unity.Services.Multiplayer/.Core/.Authentication/.Relay(assembly names verified when the package lands — NetcodeSession story 001's first task;Unity.Services.*referenced only fromRelaySessionLauncher),UnityEngine.Physics2DModule,UnityEngine.TilemapModule(ADR-0008 Migration 2, ADR-0009 Documents Amended). - (2026-09-11) New engine-free namespaces in
Rules:Rules.Movement(Locomotion,Sweep,Facing,ISweepQuery,IDisplacementSink,LocomotionMode,DisplacementRequest),Rules.Enemy(EnemyBrain,Composition,Steering,BrainState,Senses,BrainActions),Rules.Build(Footprint,BuildRules,StationPresence,StructurePlaced/Demolished,DemolishCommand),Rules.Inventoryadditions (CarryFold,CarryDelta,EquipCommand/UnequipCommand,EquipmentChanged,CarrySync,EquipmentSlot). Anything that names NGO,Physics2Dor aUnityEnginetype lives inRuntime(ADR-0009/0010/0011/0012). - (2026-09-11)
HostContexthas exactly 19 members —QueueDespawn(DespawnRequest)added besideQueueSpawn(ADR-0010);HostContextMemberCountTestsmoves 18 → 19 in the same story. ADR-0009/0011/0012 add no member.SpawnKind { SharedStorage, Enemy }. ADR-0010 fixes the storage shape (SpawnRequest { SpawnKind Kind; uint StructureInstance; Cell Origin; int Slots }); ADR-0012 §2 extends it for enemies (SpawnRequest.Enemy(EnemyActorId, EnemyId archetype, Cell, byte facing)) but does not state the merged field list — the first story that needs both kinds fixes the union and records it as an ADR-0010 amendment (TD-MANIFEST C1).DespawnRequest(uint StructureInstance). - (2026-09-11)
IHostPositionssignature is unchanged (HostKnown(ulong clientId),All());clientIdthere is a within-tick lookup handle every drained command carries, never a row key (ADR-0009 §7). - A formula needed by both a Data-stage validation rule and a Rules consumer lives in
Driftlands.Data; Rules calls in. Until a second Rules consumer exists, a duplicated arithmetic pinned by a mutation-verified agreement test is the accepted interim; the third consumer forces the move (ADR-0003 §1, 2026-09-07). - Folder layout mirrors the graph:
Assets/Driftlands/{Tide,Data,Rules,Runtime,Presentation}/, each with its asmdef at the root; tests underAssets/Driftlands/Tests/{EditMode,PlayMode}/. Editor-only tooling underAssets/Driftlands/Editor/(Driftlands.Editor, never referenced by production — ADR-0003 addendum 2026-09-03). Vector2inRulesisSystem.Numerics.Vector2.Rulesnever sees aUnityEnginevector.Cellisreadonly record struct Cell(int X, int Y)inData;gridCellSize = 1world unit.UnityInterop(inRuntime, static) is the only place Numerics↔Unity conversion lives — includingPhysics2DSweep's cast boundary (ADR-0009 §3).- Adapters (
TilemapTerrainQuery,RegistrySnapshot,HostPositions) are thin and never cached by Rules across commands. - Command records hold only intent-time data; everything else is read from
ctxinsideExecute. - Reflection fences run every CI pass, each with a companion negative test proving it fails when violated:
Asm_PureAssembliesHaveNoUnityReferences,Asm_NoUpwardReferences,Asm_NoCodeOutsideAsmdefs,Purity_NoStaticMutableFields,Commands_ExecuteIsSynchronous,Interop_SingleConversionSite,Schema_; (2026-09-11)Net_OnlyChannelDeclaresServerRpcis replaced byNet_RpcSurfaceIsClosedand joined by theNet_,Move_,Inv_,Bld_,Ai_fence families listed in the layer blocks below (ADR-0008 §9, 0009 §9, 0010 §7, 0011 §8, 0012 §7). - ≥ 80 % coverage on
Tide,Data,Rulesvia the code-coverage assembly filter — Runtime/Presentation excluded by filter, not by omission.
Tide (ADR-0001) — implemented; see Assets/Driftlands/Tide/
- Tide math uses only
System.Mathandlong/ulong/double. Implement F0–F8 exactly as specified. - Caches are instance-scoped, never
static, evicted whenroundIndexadvances, ≤ 2 rounds resident. TideClockis main-thread only.AdvanceEpochis the only method that incrementsEpochIndex(called only by GameData/Save on anNchange);Restore(TideState)assigns the persisted value. Both are reached only throughIEpochAdvancer/ISaveClock, whichTideClockimplements explicitly.AdvanceEpochthrows unlessactivationT == t(ADR-0001 + ADR-0006 §5, 2026-09-07).TideClock.ApplyPoolChange(in TideConfig)is the tide-EC13 pool-change surface: applies at the next round boundary; refuses anNchange (→AdvanceEpoch) and aW/Gchange (not a pool change) (ADR-0001, 2026-09-07).- The only impure input is
long hostUtcNowpassed intoAdvance. Advancehas exactly one caller:HostTickOrchestratorstep 1, once perNetworkTickSystem.Tick.- (2026-09-11)
treplicates asNetworkVariable<long>on the in-sceneTideClockAdapter,WritePerm = Server, default traits (delta on change —tchanges once per second by construction);TideClockAdapteris one of the in-scene objects a joiner receives at synchronisation (ADR-0008 §5/§6; tide story 010 is Ready). ITideClocknever exposes rawt,φ,C,W(AC-27).- Never cache
Evaluate()across ticks; never construct a second Authority clock. - A Presentation-role clock throws if a gameplay consumer subscribes to its events.
- The AC-2 fixed-vector fixture is generated from production constants, pinned, and must be byte-identical on Mono Editor and one IL2CPP standalone build.
Save (ADR-0004)
- Exactly one host-owned file per world at
persistentDataPath/Driftlands/worlds/<worldId>/, withsave.1/2/3.jsonrolling backups. No merge/import path may ever exist. - Newtonsoft only, and only through
ISaveWriter/ISaveReader— neverJsonConvert.SerializeObject(gameplayType). - Key order is deterministic — owners emit keys in fixed order; never iterate a
Dictionaryinto the file. - An unknown key in a supported version is an error, never round-tripped.
- Dirty-marking is main-thread, O(1), inside the critical section. File I/O is background, one job at a time, in drain order.
TideClockAdaptercallsPersist(tideTable)after everyAdvancethat changest, and afterAdvanceEpoch.- Session end flushes synchronously — the one place a blocking wait is allowed, bounded to ≤ 2 ×
flushDeadlineMs. (2026-09-11) The host'sISessionLauncher.LeaveAsync()awaitsSave.FlushAllAsync()beforeNetworkManager.Shutdown()(ADR-0008 §1). - macOS:
fcntl(fd, F_FULLFSYNC)beforeFile.Replace— plainfsyncdoes not flush the drive cache there. - Load order is fixed: 0 pick file → 1 version gate → 2 clock guard → 3 epoch check → 4 union → 5 droppable → 6 permanent → 7 inventories → 8 placement → 9 counters/
StartHost. (2026-09-11) A late joiner runs only the step-9-equivalent deserialisation of theSnapshotDocument: steps 0–2 are replaced by the connection (tfromTideClockAdapter;configVersion/dataHashmismatch → disconnectConfigVersion), step 3 ridesepochState, steps 4–7 are skipped (the host reconciled), step 8 (tide EC1 placement) moves toSessionReadyCommand.Execute— the only place it runs for a joiner (ADR-0008 §6). - (2026-09-11)
SnapshotDocumentis written through the sameISaveWriterfrom the sameSerializemethods as the save file ={worldSeed, configVersion, dataHash, t, epochState}+ the reconciled tables oflastFullDocminus every CARRY,sharedStorage,harvestDiffsandstructures(all four have live replicated forms) plus asessionActorssection ({Hp, State, BleedOutRemainingTicks, OnLand}perPlayerKeyinCombatState, connected or not — written byCombatStatethroughISaveWriterbut never registered withSaveRegistry). Built on the main thread at a drain boundary, once per join (ADR-0008 §6, amended by 0010/0011). - (2026-09-11) The
structurestable writes afootprintcolumn (w,h) per row from the def at save time — a writer column, never aStructureInstancefield (AC-24 stays literal). Reconcile: a row whose def is missing keeps its column values (relic); a row whose def exists is re-derived from the def; a pre-column save (none exist) reads as1×1+KeptOrphan(ADR-0004 stage 6 via ADR-0011). - (2026-09-11)
carries.equipmentis live: twoinstanceIdstrings (weapon, armor) —CarriesSaveTable._equipmentPassThroughbecomes the persisted half of ADR-0010 §5. - (2026-09-11) Nothing about enemies is ever saved — no
ISaveable/IReconcilableinRuntime.Enemy, noTableIdcontainingenem(enemy_save_table; fenceAi_NoEnemySaveTable, ADR-0012 §2). SaveRegistryasserts at close that exactly oneDroppabletable exists and itsTableId == "harvestDiffs".Reconcileis NOT onISaveable.ISaveable(Driftlands.Data) isTableId/Class/Serialize/Deserializeonly; the reconcile half isDriftlands.Rules.Reconcile.IReconcilable : ISaveablebecauseReconcileContextnames Rules types.HostContext.PersisttakesISaveable;SaveRegistry.RegistertakesIReconcilable(ADR-0004 §2, 2026-09-07).- Every reconcile outcome is a typed finding:
ctx.Report(ReconcileFinding { TableId, RowKey, Kind, Detail })withFindingKind { Dropped, KeptOrphan, Stale }. Never a prose string (ADR-0003 §3 / ADR-0004 §5, 2026-09-07). - Load full-restore gives full HP, clears Downed, places per tide EC1.
GameData (ADR-0006)
- Data lives under
StreamingAssets/data/, read withFile.ReadAllText(desktop only). - Shipped data is read-only. Overrides live at
persistentDataPath/Driftlands/data-overrides/<file>.jsonand replace the whole file — no key-level merge. Every active override is logged loudly at boot and shown on the main-menu debug line. GameDataLoaderruns from[RuntimeInitializeOnLoadMethod(SubsystemRegistration)].- A manifest-listed file that is missing is an error; an unlisted
*.jsonis a warning. - Validators run in fixed dependency order (stages 0–10); a stage runs only if every stage it references produced no errors.
- Boot and CI call the identical
Validation.Run. - Any validation error halts boot on the fatal-config screen;
NetworkManagernever starts. Warnings allow boot, logged, counted on the debug line. - CI fails on errors, on stage-10 gate breaches, and when the warning-set snapshot changes without being updated.
configVersionis a hand-bumped integer inmanifest.json— for derived-state/stored-key-meaning changes only, never for tuning. (2026-09-11) A joiner whoseconfigVersionordataHashdiffers from the host's is disconnected (ConfigVersion) — never silently mis-mapped (ADR-0008 §6, 0010 §1, 0011 §1).- (2026-09-11) Five new required files, each added to
ManifestRules.RequiredFilesAND bothTestJsonfixtures (EditMode and PlayMode) in the story that introduces it — the core-camera story-001 lesson:netcode.json(NetConfig, ADR-0008),movement.json(MovementConfig, ADR-0009),structures.json(StructureDef/BuildConfig, ADR-0011),enemies.json(EnemyDef/AttackDef) andenemy-ai.json(AiConfig) (ADR-0012). - (2026-09-11) Every wire/movement/AI number is a data field, none a C# constant — with one stated ceiling:
MaxCommandPayloadBytes = 64/MaxOutcomePayloadBytes = 128are the fixed-buffer sizes (compile-time) (ADR-0008 §4's prose originally read 64 for outcomes; corrected 2026-09-11 — 128 since ADR-0010'sCarrySync; the §4 diagram,NetConfigand the ADR-0010 amendment already said 128);netcode.jsonmay lower the acceptedLength, never raise it (ADR-0008 §4, 0010). - (2026-09-11) New validation rows (finite-only, per the NaN lesson):
TickRate20–60 andTime.fixedDeltaTime == 1 / TickRate;SnapshotChunkBytes ≤ 6000;RangeSlackUnits0–2;bagSlots ≤ 14(the 128 BCarrySynccap — a data tweak can never overrun the wire); the movement reader is schema-negative (rejects any key containingmodifier,stamina,encumbr,weight,penalty); the home island's placeableRegionfitsshort(Bld_RegionFitsShort);AttackDef.TelegraphMs ≥ 400per attack — hard reject (combat AC-20);guardDensityByBandmonotonic (reject on non-monotonic); deep spawn points > shore spawn points per template (warning);EnemyDefvalues insidecombat.json's HP/damage bands (warning);separationRadiusfinite/in range. - **Every duration, distance, rate, fraction or modifier on a
*Configrecord isdouble.**floatonly where a Unity API demands it (shader/tint floats,TintFloats). ADR-0003 §2's "gameplay geometry is float" is spatial, not durations.HarvestConfigshipped asfloatbefore this rule and is retyped by a follow-up story (ADR-0006 §2a, 2026-09-07). ADR-0008–0012's Key Interfaces writefloaton several config records for brevity — apply this rule when implementing them. - Epoch order is exactly:
Restore(TideState)→Advance(hostUtcNow)→ epoch check.socketCountlives in the save's tide block, never insideTideState.
Land lifecycle (ADR-0005)
- No addressable prefab is ever an NGO network prefab.
LandNetis a plain prefab inNetworkPrefabsList. (2026-09-11) So areSharedStorage(StorageNet), the player prefab and the enemy prefabs (ADR-0010 §3, 0009 §1, 0012 §2). - One local packed group per template,
Land_<templateId>; theLandVisualPrefaband itsSpriteAtlasin the same group, atlas referenced by exactly one addressable. TemplateDef.AddressableKey == "land/<templateId>".LandNet's four identity NetworkVariables are set once beforeSpawn()and never mutated; onlySoftClosedchanges. (2026-09-11)LandNet.Nodes : NetworkList<NodeNetRow>is populated byNodeListSeeder : INodeSeederbeforeSpawn()(activation step 2 → 3) and written afterwards only byLandStateAdapter.Decrement; theNodesReadycontingency is closed — spawn-time sync delivers the full list (ADR-0010 §4, V1 source-confirmed). Land story 009 is Ready.- Exactly one Addressables handle per slot, released by exactly one call. Only
LandLifecyclemay callAddressables.LoadAsync/InstantiateAsynconland/. CandidateCellsorder is preserved as authored — an array, never a set.- The tide-edge carve-out covers exactly
LandNetspawn/despawn andIKnownLandTemplates.Append. A third tide-driven host-rail mutation needs an ADR amendment. (2026-09-11) Enemy spawns are not the carve-out:EnemySpawnerbuffersQueueSpawn(Enemy …)into the tick'sHostContextfrom step 1 (LandActivated), flushed at phase 4 — deferred effects, session state, not a host-rail table (ADR-0012 §2). - (2026-09-11)
IEnemySpawner(shipped seam) is implemented byEnemySpawner;EnemyScalingContextstays empty (ADR-0012 §2). - (2026-09-11)
EvictionExpired(socket)has two named consumers andLandTornDownone:PlayerSnapServicedeposits anyone whose host-known position is still on the land (TideDeposit; ADR-0009 §4);EnemySpawnerdespawns every enemy on the socket — before theLandNetdespawn, lingers dropped (ADR-0012 §2, V3);LandTornDown→CombatState.RemoveEnemiesOn(socket)+NodeTable.OnLandTornDown.SoftCloseDeposit(socket)atφ+W→PlayerSnapServicedeposit (ADR-0009 §4). LandLifecyclereads preload scheduling only viaIPreloadSchedule, by pull;int socket, notSocketId; the eviction instant comes from this seam, never re-derived (ADR-0005, 2026-09-07).- "Debug Build Layout" and the JSON report format cannot be checked in on Addressables 2.9; asserted and corrected by
Driftlands.Editor.AddressablesSetup.Ensure()before every content build (ADR-0005 §5, 2026-09-07).
Input (ADR-0007)
InputAdapterusesInputSystem.actions— nevernew DriftlandsControls().- The adapter disables
Player/Build/UIatAwake, then enablesPlayer. - Adapter reads in
Updateat[DefaultExecutionOrder(-1000)]. Every edge consumer runs inUpdate, neverFixedUpdate. - Aim resolves through exactly one pure
AimResolver.Resolveshared by both schemes; it persists atprev, never resets to zero. Reviveis the only hold-style read, modelled asButtonState.HeldFor— not an asset-levelHoldinteraction.Strike's RT binding carries a per-bindingPressattriggerPressPoint.LoadBindingOverridesFromJsonuses default replace semantics — never passfalse.- Rebind conflicts resolve as an atomic exchange on confirm, never a clear.
MenuandUI.Cancelare protected/unbindable. bindings.jsonlives beside settings, never in the world save.- (2026-09-11)
InputFramenever crosses the wire (ADR-0008 Related);UiCapturedframes reach Movement withMove = 0from the adapter — Movement does not re-check it (ADR-0009 §2);IAimSource.AimVectoris the owner's_input.Current.Aimpass-through (ADR-0009 §8).
NetcodeSession (ADR-0008) (new 2026-09-11)
ISessionLauncheris the only type that knows how bytes reach another machine:HostAsync(maxClients) → joinCode,JoinAsync(code),LeaveAsync(),CurrentRttMs(UnityTransport.GetCurrentRtt(ServerClientId); 0 on the host). MVP =RelaySessionLauncher:UnityServices.InitializeAsync→ anonymous sign-in →CreateAllocationAsync/JoinAllocationAsync→utp.SetRelayServerData(AllocationUtils.ToRelayServerData(alloc, "dtls"))→StartHost()/StartClient(). Steam at ship is a second class behind the same interface and touches nothing else (fenceNet_SessionLauncherIsTheOnlyTransportToucher).NetworkConfig.NetworkTopology = ClientServer,ConnectionApproval = true,TickRate = 50(fromnetcode.json, validated 20–60);tickDt = 0.02 s;Time.fixedDeltaTime = 1 / TickRateis set once at boot by NetcodeSession (fenceNet_TopologyIsClientServer, PlayModeTickRate == netcode.json). Phase alignment betweenFixedUpdateandTickis not assumed anywhere.PlayerKey= a client-persistedGuid(IPlayerIdentity.Key) atpersistentDataPath/Driftlands/player-key— outside the world save; sent asNetworkConfig.ConnectionData(16 B) beforeStartClient; the host'sConnectionApprovalCallbackrejects anything not exactly 16 B orGuid.Empty("bad key") and a key already connected ("key in use"), setsCreatePlayerObject = false, and recordsclientId → PlayerKeyinSessionState. The host trusts the approval-time mapping, never aPlayerKeyinside a payload (SessionReadyechoes it for the fence only).- The RPC surface is closed at exactly three methods in
Driftlands.Runtime∪Driftlands.Presentation(fenceNet_RpcSurfaceIsClosed, by name andSendTo):HostCommitChannel.SubmitRpc(CommandEnvelope, RpcParams)[Rpc(SendTo.Server, Delivery = Reliable, InvokePermission = Everyone)]— the only inbound host-rail RPC, enqueue only, owner :=rpc.Receive.SenderClientId;HostCommitChannel.OutcomeRpc(OutcomeEnvelope)[Rpc(SendTo.ClientsAndHost, Delivery = Reliable)]— the only confirmed-event broadcast, called only from the drain's phase-4 flush;SessionNet.SnapshotChunkRpc(SnapshotChunk, RpcParams)[Rpc(SendTo.SpecifiedInParams, Delivery = Reliable)]targetingRpcTarget.Single(joiner, RpcTargetUse.Temp). HostCommitChannelandSessionNetare two separate in-scene, always-spawned, server-ownedNetworkObjects — a 40 KB snapshot burst must never sit in front of a strike envelope (bulk_payload_on_command_channel).BaseNet(ADR-0011) andTideClockAdapterare the other in-scene network objects.- Envelopes are
unsafefixed-layout structs:CommandEnvelope { byte Kind; uint ClientSeq; byte Length; fixed byte Payload[64] },OutcomeEnvelope { byte Kind; ulong OwnerClientId; byte Length; fixed byte Payload[128] };NetworkSerializewritesLengthbytes one at a time — never abyte[](byte_array_on_command_wire).SnapshotChunk { ushort Index, Count; uint DocLength; byte[] Bytes }is the onebyte[]on the wire (a join allocates a few dozen 4 KB arrays once). - One sealed codec per
CommandKind/OutcomeKind(ICommandCodec/IOutcomeCodecinRuntime.Net.Codecs, static read-only table keyed by kind): fixed-layout blittable fields, little-endian,WriteValueSafefield by field; no strings, no arrays, no nullable references;Vector2→ twofloat, ids → theirint/uint,PlayerKey→ 16 B,Cell→ twoint. The codec is whereIntentTick = nowTickis stamped and where aNode-kindStrikeIntentbecomesHarvestResolveCommand. The codec is the only place a Rules record meets a byte (fenceNet_CodecsFitTheCap: size ≤ cap, exactly one codec per kind, round-trip identity per fixture). CommandRejected(ClientSeq, ReasonCode)is 5 B and carries no state — sent to the owner via the same broadcast withOwnerClientIdset; other clients ignore it; aReasonCodenaming world state is rejected at review (fenceNet_RejectionCarriesNoState).ReasonCodegainsNotReady;CommandKindgainsSessionReady,Equip,Unequip.- Representation rule: a value whose current state a late joiner needs = server-written
NetworkVariable<T>/NetworkList<T>on the object that owns it; a fact observed as an edge =OutcomeRpc. Never both for one fact — with one registered exception:InstanceMetadata(snapshot-delivered and append-only) carries its new row onCraftCommitted, applied insert-if-absent byInstanceId. Predicted FX (stance, whiff, ghost) are local only, never replicated. The full table is ADR-0008 §5 — consult it before adding any replicated field. - Late join: approval → in-scene objects sync (
HostCommitChannel,SessionNet,TideClockAdapter,BaseNet,LandNets with their lists) →SnapshotChunkRpc × N(≤ 4 096 B each; apply once every chunk is present) → clientSubmitRpc(SessionReady{PlayerKey})→SessionReadyCommand.Execute:CombatState.GetOrCreate(PlayerKey), placement per tide EC1 re-samplingctx.SoftClosedSocketsandctx.Tide.EvaluateinsideExecute,Broadcast(CarrySync)to the owner,ctx.QueueSpawn(PlayerSpawn(clientId, key, pos)). The host's own player takes the same path —StartHostenqueues a localSessionReadyCommand(ADR-0010 §2).SessionReadybefore the snapshot has applied →CommandRejected(NotReady), the joiner retries; any other command from a joiner without an actor row →CommandRejected(ActorDead). - Rejoin = late join (TR-net-004): the actor row survives the disconnect;
GetOrCreatefinds it; HP/Downed/bleed-out restore. Disconnect: the body despawns (DontDestroyWithOwner = false),SessionStatekeepsPlayerKey → row, theclientIdis released (NGO may reuse it — never a key); in-flight envelopes already received execute. - Host quit:
LeaveAsync()awaitsSave.FlushAllAsync()thenNetworkManager.Shutdown(); each client'sOnConnectionEvent(Disconnected)routes to the title screen with the reason string. No host migration. - Receipt-time validation table (inside
SubmitRpc/Enqueue, no gameplay knowledge):Kindin enum range;Length ≤ MaxCommandPayloadBytesand equals the codec's fixed size;ClientSeq > last[PlayerKey];ClientSeq ≤ last + seqWindow(64); ≤maxEnvelopesPerTickPerClient(16) — each failure drops and counts (SessionState.DroppedByRule[Kind], exposed to the HUD debug overlay and the playtest CSV). Gameplay gates stay where ADR-0002/0015 put them. - Proximity slack is fixed, never RTT-scaled (TR-combat-003):
HostKnown(attacker)withinrange + RangeSlackUnits(1.0 u) of the referent — inExecute.ResolveTicksanity (≥ NowTick,≤ NowTick + maxResolveAheadTicks50) is an assertion, not a gate. - Timeouts (UTP defaults, kept):
DisconnectTimeoutMS30 000,HeartbeatTimeoutMS500,ConnectTimeoutMS1 000 ×MaxConnectAttempts60 — a disconnect is a late join later, so a long timeout costs only a frozen remote body. NetworkTransformbaseline (both rails): tick-rate sends,Interpolate = trueon non-authority,PositionThreshold = 0.01(players) /0.02(enemies),UseUnreliableDeltas = true,PositionInterpolationType = Lerp,UseHalfFloatPrecision = false,SyncPositionZ = false, rotation/scale sync off.NetworkVariabletraits default everywhere exceptEnemyNet.AnimFrame(MinSecondsBetweenUpdates = 0.05).- Data-file identity on the wire:
dataHash= FNV-1a over each id-index table's own hash in a fixed, append-only order — items, structures, enemies (ADR-0010/0011/0012); a later table appends, never reorders; carried in the snapshot header; mismatch →ConfigVersiondisconnect. - Store the 2026-09-11 kit's
RelaySession.csas reference only —prototypes/combat-netcode-feel-concept/is throwaway and is never refactored into production (ADR-0008 Related).
Forbidden (Foundation)
- Never use
System.Random,GetHashCode(),UnityEngine.Mathf,float, transcendentals, orDateTime.Nowin tide schedule math — it breaks host/client determinism (AC-4). - Never iterate elapsed cycles to "catch up" (
iterating_elapsed_cycles) — it reintroduces offline simulation, which Pillar 1 bans. - Never let a Presentation-role clock's events drive a gameplay decision (
presentation_clock_driving_gameplay). - Never reference
UnityEngine/UnityEditor/Unity.*fromTide/Data/Rules(unity_refs_in_tide_assembly) — verified 2026-09-02 to be a hardCS0246build failure, not a review discipline. - Never put a non-
const/non-readonlystatic mutable field in a pure assembly (static_cache_in_pure_assembly) — disabled domain reload carries stale state across Play sessions. - Never make
Executeasync or re-entrant (async_or_reentrant_execute). - Never declare a host-rail RPC outside
HostCommitChannel(host_rail_rpc_outside_channel) — (2026-09-11) and never a fourth[Rpc]of any target anywhere in Runtime/Presentation (Net_RpcSurfaceIsClosed). - (2026-09-11) Never call
SetConnectionData(ip, port)on a Relay session; never referenceUnity.Services.*orSetRelayServerDataoutsideRelaySessionLauncher(ADR-0008 §1). - (2026-09-11) Never
NetworkTopology = DistributedAuthority(distributed_authority_topology); never assignNetworkObject.CheckObjectVisibility(relevancy_filtering; fenceNet_NoRelevancyFilter). - (2026-09-11) Never construct a
NetworkVariable<>/NetworkList<>withNetworkVariableWritePermission.Owner(owner_written_network_variable; IL-scan fenceNet_NoOwnerWrittenNetworkVariables). - (2026-09-11) Never put a
byte[], string, array or nullable reference in a command/outcome payload (byte_array_on_command_wire); never let a codec exceed the 64 B / 128 B fixed buffers (Net_CodecsFitTheCap). - (2026-09-11) Never send bulk state (snapshot chunks, table dumps) through
HostCommitChannel's object (bulk_payload_on_command_channel). - (2026-09-11) Never give
CommandRejecteda payload beyond(ClientSeq, ReasonCode)(Net_RejectionCarriesNoState). - (2026-09-11) Never trust a
PlayerKeycarried in a payload — the approval-timeSessionStatemapping is the only source (ADR-0008 §1/§4). - (2026-09-11) Never register the drain on a
NetworkUpdateLoopstage to "fix" the one-tick state lag — the lag is a known, bounded cost; an undocumented stage coupling is not (ADR-0002drain_hook, re-affirmed by ADR-0008 §2). - (2026-09-11) Never replicate CARRY as state (
carry_on_wire; fenceInv_NoCarryOnWire) — noNetworkVariable<>/NetworkList<>whose type argument namesItemStack,StackBag,CarryRoworCarryInventory;CarryViewderives from noNetworkBehaviour(ADR-0010 §2). - Never mix Numerics and Unity vector types in one signature outside
UnityInterop(conversion_outside_unityinterop). - Never build a merge/import path for a second save file.
- Never run serialisation or a file swap inside
Execute/Drain(save_io_inside_drain) — unbounded disk I/O on a 16.6 ms frame. (The snapshot is built at a drain boundary, once per join — not insideDrain.) - Never reflection-serialise gameplay types (
reflection_serialize_gameplay_types). - Never construct partial state on a version-gate failure — refuse loudly. (2026-09-11) Same for a joiner's
configVersion/dataHashmismatch — disconnect, never mis-map. - Never let a background flush touch a live
UnityEngineobject. - Never silently migrate across an unsupported
saveFormatVersion/configVersion. - Never key-level merge a data override file.
- Never fall back to a default for an unknown or missing required key (
silent_default_on_missing_key). - Never place code outside an asmdef (
code_outside_asmdef). - Never report a kept row through a channel named for drops, and never add a second reconcile channel beside
Report(ADR-0003 §3 C4, 2026-09-07). - Never reference
Driftlands.Editorfrom a production asmdef (ADR-0003, 2026-09-07). - (2026-09-11) Never add a required data file without adding it to both
TestJsonfixtures in the same change — a class-filtered run reads green while three PlayMode boot tests go red (core-camera story-001).
Guardrails (Foundation)
| Budget | Value | Measured against |
|---|---|---|
| Tide evaluation | ≤ 50 µs avg per combined DockedLands+NextBoundary at N ≤ 8 (hard) | Editor profile, stopwatch |
| Tide allocation | 0 bytes per warmed call (hard) | GC.GetAllocatedBytesForCurrentThread() |
| Tide per-frame | ≤ 0.1 ms (advisory, AC-46) | tint-float evaluation |
| BB ghost preview | 0 B per-frame allocation in Rules.Build | Profiler |
| Coverage | ≥ 80 % on Tide/Data/Rules | assembly filter |
flushDeadlineMs | 250 ms — conservative, unmeasured; retune before Alpha | real fsync/F_FULLFSYNC on SSD and HDD+AV |
| Session-end wait | ≤ 2 × flushDeadlineMs | the only permitted blocking wait |
| Boot | < 100 ms for load + validation of ~17 files | CI |
| Land preload | PreloadLeadTime 600 s vs ≤ 64 MB LZ4 bundle | zero I/O at the φ edge |
| Per-template memory | landTemplateBudgetMB = 64 (32–128) | parsed Addressables build layout; FAIL if the file is absent — never pass vacuously |
| netcode-session per tick (2026-09-11) | ≤ 0.2 ms on the host (codec decode + validation + OutcomeRpc flush), excluding the drain's 1 ms | Editor profile at 50 Hz |
| Per-command allocation (2026-09-11) | 0 B in steady state (fixed-buffer envelopes) | AllocatingGCMemory constraint plus a control allocation — a meter that has never read non-zero proves nothing (tide story-006) |
| Bandwidth (2026-09-11) | ≤ 20 KB/s down / 5 KB/s up per client at 2 players + 8 enemies (expected ≈ 15 / 2) | RttLogger-style CSV on both machines |
| Snapshot build (2026-09-11) | ≤ 5 ms once per join, main thread, at a drain boundary | measured in the join story |
| Snapshot chunk (2026-09-11) | ≤ 4 096 B (MaxPayloadSize 6 144); a 40 KB fixture round-trips byte-identical | V3 |
| Relay sign-in + allocation (2026-09-11) | ≈ 1–2 s on a normal link (measured 2026-09-11) | title-screen host/join spinner budget |
Engine constraints (Foundation)
System.Text.Jsonis NOT shipped with Unity 6.3 (verified live) — Newtonsoft is primary; STJ stays a future swap behind theISaveWriter/ISaveReaderseam.FileStream.Flush(true)does not flush the drive cache on macOS —F_FULLFSYNCP/Invoke required; verify on Apple-silicon and Intel.File.ReplaceisReplaceFile()on Windows, POSIXrename()elsewhere — atomic on one volume only.Application.wantsToQuitdoes not fire on Play-mode exit — useEditorApplication.playModeStateChanged == ExitingPlayModefor Editor parity.- Domain-reload-disabled hazard: a
staticflush worker would survive Play sessions.SavePersistenceis session-owned; cancel the worker and clear the queue onExitingPlayMode. (2026-09-11) The same hazard applies toStorageNet's static spawn dictionary (ADR-0011 §4) andNetworkTransform.InterpolationBufferTickOffset(a static) — reset both on session start. [RuntimeInitializeOnLoadMethod(SubsystemRegistration)]before sceneAwakeis confirmed; that it precedes a scene-placedNetworkManageris a project ordering fact to test, not a Unity guarantee. (2026-09-11) It must:BaseNet.Structuresis filled from the reconciled registry afterSave.Load()and beforeStartHost(CHANGELOG #3177;BaseNet.Initialisethrows ifIsListening).- ⚠️ UNVERIFIED:
System.Numerics.Vector2under a stripped IL2CPP build. It is scalar (no SIMD) on Mono and IL2CPP — chosen for shape, never speed. - (2026-09-11, resolved) NGO 2.x write-permission API is verified from source:
new NetworkVariable<long>(writePerm: NetworkVariableWritePermission.Server);NetworkList<T>(IEnumerable<T>, readPerm, writePerm); traits viaSetUpdateTraitsafter construction.modules/networking.mdis 1.x-era and is used for nothing — the pinned facts above and ADR-0008's(src)markers are the reference. - (2026-09-11)
PlayerKeyGUID on disk is trivially copyable — a friend can impersonate a friend. Accepted: 2–5 friend co-op, no economy, no anti-cheat requirement; the host re-samples every gate regardless. - ⚠️ UNVERIFIED: 0-alloc (AC-45) is Editor/Mono-measured, and
doubleparity Mono↔IL2CPP is a verification item, not an assumption.
CORE LAYER
PlayerMovement · Combat · Harvesting · Inventory · LandContent · Camera
Required
Host commit model (ADR-0002, amended 2026-09-11 by ADR-0008/0009/0010/0012)
HostCommitChannelis the only NetworkObject declaring a host-rail[Rpc(SendTo.Server)]. Exactly one inbound RPC:SubmitRpc, attributed[Rpc(SendTo.Server, Delivery = RpcDelivery.Reliable, InvokePermission = RpcInvokePermission.Everyone)]— notRequireOwnership(obsolete; a warning-producing attribute is a fence failure). Exactly one outbound:OutcomeRpc.- The host overwrites the sender id from
rpc.Receive.SenderClientId— never trusts the payload. Enqueuenever executes. FIFO with host-assignedarrivalSeq.Drain()runs exactly once per tick, in fixed order: (1) SoftClose set → (1b)PendingEnemyDamage→ (2) envelope loop byarrivalSeq→ (3) teardown release → (4) buffered broadcast/spawn/despawn flush → (2026-09-11) (4b)StorageReplicator.AfterDrainandRegistryReplicator.AfterDrainwrite theStorageNet.Rows/BaseNet.Structuresmirrors from the dirty set → (5)Persistdirty-mark /Save.EndOfDrain.- (2026-09-11)
Broadcast,QueueSpawnandQueueDespawnare callable from tick steps 1–3 (Advance,CombatState.Tick,EnemyAI.Tick) through the tick'sHostContextas well as fromExecute; the phase-4 flush drains everything buffered since the top of the tick — tick-step effects first (buffering order), then command effects (command order). ADR-0002's "a command never observes its own broadcast" is now "nothing observes a broadcast before the phase-4 flush" (ADR-0009 C1, ADR-0012 §2).spawn_or_send_inside_executestays scoped toExecute. - (2026-09-11) A
NetworkVariable/NetworkListwritten inDrain()replicates at the next tick; outcomes leave in the same tick's batch. Budget the one-tick lag; never "fix" it (ADR-0008 §2). - Every gate is re-sampled fresh inside
ExecutefromHostContext— never cached from intent time. - Confirmed outcomes broadcast only after
ExecutereturnsSuccess; a failed command is a silent honest no-op for the world — (2026-09-11) the owner alone receivesCommandRejected(ClientSeq, ReasonCode), state-free, to un-predict its own FX and drop its pending bag delta. - Dedup keys the last
clientSeqperPlayerKey—clientIdis unstable across reconnect. (2026-09-11) Plus the receipt-time window/flood bounds of ADR-0008 §7. - SoftClose is evaluated first, before any command touching that socket. The drop rule is uniform across every
CommandKind— no per-kind exception. (2026-09-11)SessionReadyCommandtargets no socket, so the step-2 drop rule does not apply to it; its placement re-samples soft-close insideExecuteinstead (ADR-0008 §6). - (2026-09-11) A
HostPositions.Override(snap) is a position fact, not a gate: a queued command from an overridden player still runs under the tick-threshold rule; a position-dependent gate reads the overridden position and whiffs — it is not dropped. No command inspectsOverridedirectly (ADR-0009 §4 C2). - (2026-09-11)
CommandKind= the ADR-0002 list +SessionReady,Equip,Unequip; extended only by ADR. Per-kind fixed payload layouts are ADR-0008 §4.
Combat (ADR-0015, amended 2026-09-11 by ADR-0008/0009/0010/0012)
- Exactly one
HostTickOrchestratorsubscribes toNetworkTickSystem.Tick. Order: (1) tideAdvance→ (2)CombatState.Tick→ (3)EnemyAI.Tick→ (4)Drain. (2026-09-11) Enemy phase timers advance in step 2, and step 2'sTelegraph → Activetransition firesEnemyAttackResolver— so the brain's step-3 read ofCombatPhaseis the same tick's post-advance phase andPendingEnemyDamageis consumed in phase 1b the same tick (ADR-0012 C3; supersedes ADR-0015 §4's "step 3 advances"). IntentTickis host-stamped at receipt (ctx.NowTick) — (2026-09-11) in the codec — never client-trusted (client_stamped_intent_tick).StrikeResolve's 17 B layout carries no tick.- All combat/player rows key on
PlayerKey, neverclientId(clientid_as_row_key). (2026-09-11)PlayerKeyis the client-persisted GUID from the connection-approval payload (IPlayerIdentity); Steam ID is a second provider at ship, same 16 B shape (ADR-0008 §1). - Every owner→host combat fact travels as an
ICommandon the channel (owner_to_host_fact_outside_channel). - Correlate on
(PlayerKey, SwingId)—SwingIdis unique only within a client. - Phase-0 enemy damage applies in step 1b, before the envelope loop, so a mutual kill resolves both.
HitPipelinerounding usesMidpointRounding.AwayFromZeroand must never be referenced from a Burst assembly (burst_in_rules).- Knockback accumulator is cleared at the top of every drain; non-empty at entry is an assertion failure. (2026-09-11) Knockback leaves combat as
IDisplacementSink.Displace(DisplacementRequest{Kind = Knockback, Velocity = vector / knockbackSecs, RemainingSecs})on the enemy'sKinematicMover2D—EmitKnockbacks(IDisplacementSink); it slides along walls like ordinary movement (no terrain kills). The dash isDisplace(Dash)from the owner combat machine onDashingentry (ADR-0009 §2). SwingLanded/DamageAbsorbedare raised only from the buffered flush — never from prediction, never fromTick. (2026-09-11)SwingLandedcarriesVictim : ActorId,StaggerFired,Lethal— the enemy hit-flash/death edge; no newOutcomeKindfor enemy stagger/death (ADR-0012).- Downed/Dead suppress Strike/Dash/Demolish inside
PlayerCombatMachine.Step— never in the input adapter, never viaUiCaptured. - Crawl is a distinct locomotion mode (
downedCrawlSpeed), never a multiplier onmoveSpeed(movement_speed_modifier_for_downed). (2026-09-11) The owner mapsPlayerState → LocomotionMode:Idle/Windup/Active/Recovery → Normal,Downed → Crawl,Dead → Frozen,Dashing → Normal + active displacement(ADR-0009 §2). ICombatState : ISwingerStateQuery—CanResolveHarvest(PlayerKey),AcceptsHarvestIntent(PlayerKey); Harvesting sees only these two members (ADR-0015, 2026-09-07).- (2026-09-11) Replicated combat rows on the wire: one
NetworkVariable<PlayerActorNet>{int Hp, byte State, int BleedOutRemainingTicks}onPlayerNet(server-write, written byCombatStateat end ofTick/drain) andEnemyNet.Phase : NetworkVariable<byte>. Enemy HP, stagger meter and immunity never replicate (ADR-0008 §5). - (2026-09-11)
EnemyActorId(SocketId Socket, ushort Index)replaces ADR-0015's per-actorEnemyId(landSocket, index)—Data.EnemyIdis the archetype string key. Closed call-site list that changes type:ActorId = … | EnemyActorId, theEnemyActorrow key,ICombatState.Enemy(EnemyActorId),ApplyHit(EnemyActorId, …),PendingEnemyDamage.Attacker, step 2's angle tiebreak on.Index;EnemiesOn(SocketId)unchanged. Wire formEnemyActorIdNet{byte Socket, ushort Index}(ADR-0012 §1). - (2026-09-11) The AI→combat boundary is exactly two calls:
IEnemyCombat.EnterTelegraph(attackId)andIEnemyCombat.LeashReset()(HP to max, meter and immunity cleared) — fenceAi_BoundaryIsTwoCalls(ADR-0012; user decision). - (2026-09-11)
CombatState's single-writer rule has a fourth named site: the phase-4 deferred-spawn flush forICombatState.AddEnemy(EnemyActorId, EnemyId archetype)/RemoveEnemiesOn(SocketId)— row creation/destruction only, never a field write on a live row; called only byEnemySpawner(ADR-0012 C2).EnemyActor.Targetmoves out of combat into the AI'sBrainState. - (2026-09-11)
PlayerActor.Equipped{Weapon, Armor}is written only byEquipCommand/UnequipCommand.ExecutethroughICombatState.SetEquipped(key, slot, instanceId?);IGearHooksreads unchanged (ADR-0010 §5). - (2026-09-11)
Snap(DeathRespawn, homeSpawn)isIPlayerSnapper.Snap(key, SnapReason.DeathRespawn, homeSpawn)onPlayerSnapService(host) — combat never touches a transform (ADR-0009 §4).
PlayerMovement (ADR-0009) (new 2026-09-11)
- Player prefab:
NetworkObject(DontDestroyWithOwner = false) ·PlayerTransform : NetworkTransformwithAuthorityMode = Owner,SyncPositionX/Y = true,SyncPositionZ = false, rotation/scale sync off,Interpolate = true,Lerp,PositionThreshold = 0.01,UseUnreliableDeltas = true,UseHalfFloatPrecision = false,InLocalSpace = false· kinematicRigidbody2D(simulated = true,useFullKinematicContacts = false,interpolation = None) that exists only soRigidbody2D.Castsees the collider ·CapsuleCollider2D/BoxCollider2D0.8 × 0.8 u on layerPlayer,isTrigger = false·KinematicMover2D·PlayerMotor(enabled only on the owner) ·PlayerNet(fenceMove_PrefabAuthority, YAML read). - Enemy prefab:
EnemyTransform : NetworkTransforminAuthorityMode = Server, same interpolation settings,PositionThreshold = 0.02; same kinematicRigidbody2D+ collider on layerEnemy; sameKinematicMover2D, driven byEnemyMotoron the host (ADR-0012). KinematicMover2Dis the ONLY writer oftransform.positionfor players and enemies —Drive(move, mode, dt)=Locomotion.Step+Sweep.Move+ write;Displace(DisplacementRequest),CancelDisplacement(),ResetVelocity(),CorrectTo(pos)(EC21: direct write, noTeleport).- Locomotion is a pure step, owner-driven, per rendered frame (
Rules.Movement.Locomotion.Step(state, move, mode, dt, cfg)):Frozen → 0;Crawl → normalize(move) × DownedCrawlSpeed × dt;Normal → normalize(move) × MoveSpeed × dt(accelTime 0: full speed the frame input arrives, stop the frame it ends; when > 0, a first-order approach — the code path exists so the playtest can try 40 ms without a code change); an active displacement isVelocity × dtforRemainingSecs, then input again. Swing phases do not slow movement. - Dash =
DisplacementRequest{dir × dashSpeed, dashDuration, Dash}from the owner combat machine;dir= movement input if non-zero, else aim. The sweep still applies — a wall stops the dash at its face and the remaining time is consumed (no bounce, no slide-along for dashes; ordinary movement slides). The host never reconstructs dash displacement;DisplacementRequestis never replicated; i-frames are combat's (EC16), not movement's.dashSpeed/dashDurationstay incombat.json. - Collision — one sweep rule, pure, over an adapter:
Rules.Movement.Sweep.Move(from, delta, ISweepQuery q, skin)moves along X (q.Cast(from, (dx,0), |dx| + skin), stopskinshort of the first hit), then along Y from the reached point. Axis-separated sliding is deliberately naive (diagonal into a wall slides; a corner stops both axes). No penetration resolution — a body that starts inside a collider is EC21's case, never the solver's.Physics2DSweep : ISweepQuery=Rigidbody2D.Cast(dir, ContactFilter2D{layerMask = blockers, useTriggers = false}, RaycastHit2D[8] preallocated, distance),hitDistance = min(results[i].distance), zero allocation. - Layers:
Player,Enemy,Structure,Terrain,WorldBounds. Matrix (fenced fromDynamicsManager.asset,Move_LayerMatrix):Player↔Playeroff,Player↔Enemyoff,Enemy↔Enemyoff (separation steering is ADR-0012's job, not the solver's);{Player, Enemy} ↔ {Structure, Terrain, WorldBounds}on; nothing else collides with anything (strike arcs are geometry in Rules, not triggers). Blockers: structures = staticBoxCollider2Dper registry row onStructure(ADR-0011); terrain = the land's authoredTilemapCollider2D+CompositeCollider2DonTerrain; world bounds =EdgeCollider2DonWorldBounds.MovementConfig.BlockerLayers = ["Structure","Terrain","WorldBounds"]. Physics2D.queriesStartInColliders = falseproject-wide (Physics2DSettings.asset, fenceMove_QueriesStartOutside) — otherwise a cast whose origin overlaps a collider (a wall placed on the player) self-blocks at distance 0. A PlayMode test covers a body that starts overlapped and must still move away.- Host-commanded snaps — the closed list, owner-honoured: triggers are exactly
SoftCloseDeposit(socket)atφ+WandEvictionExpired(socket)(→depositPoint,SnapReason.TideDeposit) and combatOnZeroHp → Dead(→homeSpawn,DeathRespawn).PlayerSnapService.Snap(key, reason, pos)on the host doesHostPositions.Override(key, pos, until NowTick + snapGraceTicks)immediately (host truth now) andctx.Broadcast(PlayerSnapped(key, reason, pos)); the owner'sPlayerMotor.OnSnappeddoesCancelDisplacement,ResetVelocity,_nt.Teleport(pos, transform.rotation, transform.localScale)(live rotation/scale, never identity) and raisesISnapSource.Snapped(reason, pos)(camera hard cut). The override ends when the owner's next NT state lands withinsnapAcceptRadius(1.0 u) or at the grace tick (25). Rejoin/late-join placement is spawn-time (QueueSpawn(player, pos)), not a snap.PlayerSnappedis 25 B (OutcomeKind). SnapReasonis exactly{TideDeposit, DeathRespawn}— the enum the camera epic shipped; EC21 never grows it (fenceMove_SnapReasonClosed).- BB EC21 is an interpolated write, not a cut (ratified): the owner computes
Rules.Build.FindCorrectionCelland callsPlayerMotor.CorrectTo(cell.Center)— a directtransform.positionwrite on the authority, notTeleport(), notSetState; NT commits it as an ordinary delta; observers slide the one cell over the buffer; the mover snaps (velocity reset, displacement cancelled); noISnapSourceevent. Triggered from theBaseNetlistAddevent, never fromStructurePlaced(ADR-0011 §6). Teleport(andSetState(occur in exactly one method:PlayerMotor.OnSnapped(teleport_outside_snap_path; raw-text fenceMove_TeleportOnlyFromSnap).- Remotes interpolate, never extrapolate: non-owner instances
Interpolate = true,Lerp(neverLegacyLerp— it ignoresTickLatency),PositionMaxInterpolationTime = 0.1 s;NetworkTransform.InterpolationBufferTickOffset = cfg.RemoteInterpBuffer(default 1, range 1–3) set once at boot — it is a static that applies to everyNetworkTransform. Effective buffer =max(1, TickLatency + offset), RTT-adaptive. Amendment order if the warp counter disagrees: offset 1→2, thenSmoothDampening, never extrapolation; when players and enemies want different buffers, players win — enemies getSmoothDampeninginstead of a larger offset (Pillar 4). IfOnUpdateis ever overridden, non-authority callsbase.OnUpdate(). HostPositions.HostKnown(clientId)is the raw last-received authoritative position captured byPlayerTransform.OnNetworkTransformStateUpdatedon the host (V1) — never the host's interpolated copy (a further buffer behind); the host's own player reads the livetransform; a pending snap override wins until it expires. Staleness bound for every gate: ≤ 1 RTT + 1 tick. Consumers (noPlayerOccupancy,placerHasExit,ArcTestattacker position, harvest proximity, tide deposit "who is on this land", craftingstationPresentasker) are single-tick predicates — none derives velocity, continuity or a delta across ticks (host_known_is_not_continuous); a future consumer needing continuity amends ADR-0009.- Aim is not replicated at MVP. Remote players face their last non-zero movement direction (from interpolated velocity); the owner faces its aim (
Facing.Octant). Sprite facing isSpriteRenderer.flipX, neverlocalScale.x = −1— so a teleport can never pop the facing. Player flipbooks derive locally fromPlayerActorNet.State+ velocity;AnimFramereplication is enemies-only. movement.json/MovementConfig:MoveSpeed 6 (5–8),AccelTime 0 (0–0.08),DownedCrawlSpeed 2 (1.5–3),RemoteInterpBuffer 1 (1–3),SnapAcceptRadius 1.0 (0.5–2),SnapGraceTicks 25 (5–100),SkinWidth 0.01 (0.005–0.05),BlockerLayers; schema-negative reader; fenceSchema_NoMoveSpeedModifier(no member matchingMoveSpeedModifier|Stamina|Encumbrancein Rules/Runtime).- Enemy overlap is recoverable only by steering: with
Enemy↔Enemyoff, two knockbacks can leave two enemies coincident; ADR-0012 owns the deterministic tiebreak (below). Never random jitter.
Harvesting (ADR-0015 / ADR-0010 §4, amended 2026-09-11)
LandStateAdapter.Decrement(node + diff row in one call) additionally setsLandNet.Nodes[NodeIndex] = new NodeNetRow(NodeIndex, after.SwingsRemaining)in the same critical section — the list can never disagree with the table.NodeNetRow{ushort NodeIndex, byte SwingsRemaining}(3 B), index-aligned withLandInstance.Nodes(NodeIndex == NodeId.Value);swingsTotalis data, not replicated. OnlyNodeListSeederandLandStateAdapter.DecrementwriteNodes[(fenceInv_NodesSingleWriter).HarvestResolved{Player, Socket, Node, Item, Yield}is the host-confirmed edge (Itemcrosses asItemIndex); harvest yields are never predicted. The post-DecrementBagFullpath spends the swing and adds nothing: the node row moves, the owner receivesCommandRejected(BagFull)whoseClientSeqmatches no pending entry — the fold treats that as a no-op onConfirmed, by rule (Inv_FoldHandlesUnmatchedRejection).- A mid-docking joiner receives the whole node list with the land's spawn (EC16); staleness is bounded by the one-tick lag.
harvestDiffsis host-only (save table); the list is the only node truth a client sees. - Proximity sanity check (TR-harv-007):
HostKnownwithinrange + RangeSlackUnits— fixed, never RTT-scaled (ADR-0008 §7).
Inventory replication (ADR-0010) (new 2026-09-11)
- The host's
InventoryTxtables are the only truth. Every bag/chest mutation is a command'sExecute. The spec's "owner-authoritative CARRY" reduces on the wire to owner-visible and owner-predicted — this ADR states the reduction; no second authority exists. - CARRY never rides a
NetworkVariable. The owner'sCarryView(Confirmed : StackBag+Pending : List<CarryDelta>;Visible= confirmed with pending applied) is seeded by the owner-addressedCarrySync{rows[bagSlots], equipment[2]}outcome sent fromSessionReadyCommand.Execute— the same drain that creates the row, closing the snapshot→ready window — and thereafter is a pure fold over the ordered outcome stream:Rules.Inventory.CarryFold.Apply(confirmed, pending, outcome, self, data), total over every bag-touchingOutcomeKind(switch with no default fall-through — fenceInv_CarryFoldIsPure). (ADR-0010 §2/§7 prose originally wrote a three-argument shorthandApply(bag, outcome, data); corrected 2026-09-11 — the five-argument form is binding because only it can consume aPendingentry, which theInventoryTransferred/CommandRejectedrows require.) Deltas:HarvestResolved+yield;InventoryTransferred∓ per direction and consumes the matchingPendingentry (FIFO);CommandRejecteddrops the entry with thatClientSeq(or no-ops);CraftCommitted−recipe inputs +output taggedInstanceId(fromIGameData.Recipes);StructurePlaced−StructureDef.Costfor the placer;StructureDemolished+100 % of current cost for the demolisher, nothing whenRefundIsZero;EquipmentChangedmoves the tagged stack between bag and slot.CarrySyncis also the only resync tool (host-initiated at rejoin; no client "request resync"). - Prediction is for the owner's own transfer clicks only: on deposit/withdraw the owner submits
InventoryTransferand pushesCarryDelta{ClientSeq, Direction, Item, Count, Tag}toPending;Visibleupdates that frame. Never predicted: the storage side (StorageViewrendersStorageNetrows only), harvest yields, crafts, placements.transferBatchWindow(default 0) coalesces same-direction/item/tag clicks into one command if raised — no wire change. - A future bag-touching command must broadcast a delta-bearing outcome (
bag_mutation_without_outcome) — the total-switch fence catches a declared kind with no fold case; it cannot catch a mutation with no outcome, which is why this is reviewed per command. The two shipped exceptions are stated: harvestBagFull-after-DecrementandInventoryTransfer's restore path (net-unchanged, dirty-marks neither). - Ids on the wire — two deterministic codecs:
ItemId(string) ↔ushort ItemIndexviaItemIndexTable= ordinal-sortedIGameData.Itemskeys,0xFFFF= empty (fenceInv_ItemIndexIsSortedOrdinal; hash folded intodataHash);InstanceTag(string,null= untagged) ↔uint InstanceIdviaTagCodec— a tag is the decimal rendering of crafting's host-monotonicinstanceId(0⇄null); a non-numeric tag is a fixture failure, at runtime kept +KeptOrphan(crafting Rule 9), encoded as0on the wire (lossy for orphans; views never write back).NodeId.Value == NodeIndex.ContainerId.Value == "s" + structureInstanceId(accepted by ADR-0011).PlayerKey= 16 B. SharedStorage= oneStorageNetNetworkObjectper container (prefab inNetworkPrefabsList, not addressable):NetworkVariable<uint> StructureInstance(set beforeSpawn()) +NetworkList<StackNetRow{ushort ItemIndex, ushort Count, uint InstanceId}> Rows(8 B/row), fixed length =storageSlots, one row per slot,0xFFFF= empty; a transfer changes exactly the rows it touches via the indexer (Rows[i] = row), neverAdd/RemoveAt— the UI slot grid maps 1:1 (fenceInv_StorageRowsFixedLength).- Spawn/despawn ride the drain's deferred effects:
PlaceStructureCommand(storage category) →ctx.QueueSpawn(SpawnRequest.SharedStorage(instance, origin, slots))→ after the loop: instantiate, setStructureInstance, fillRowsfrom the table snapshot,Spawn();DemolishCommandgatesChestNotEmptythenctx.QueueDespawn(instance)→Despawn(destroy: true).StorageSpawned/Despawnedare not outcomes — the object's existence is the state;StructurePlaced/Demolishedare the presenter edges. StorageNet.Rowsis a mirror, not host-rail state: no command gate reads it, no save table contains it, it is reconstructible fromInventoryTxat any instant.StorageReplicator(host) is its only runtime writer — once after everyDrain(), for every container in the dirty set (ctx.Persist(inv.StorageTable)), diffingSnapshot.SlotsagainstRowsand writing only changed indexes (storage_rows_written_outside_replicator; raw-text fenceInv_StorageRowsSingleWriter:Rows[/.Add(/.Clear(only inStorageReplicatorandStorageNet.Initialise).- Load: the reconciled
sharedStoragetable is applied to tables beforeStartHost; containers are spawned when the registry is instantiated (ADR-0011's boot step) withRowspre-filled; a late joiner receives everyStorageNetwith its rows in the spawn message — which is why the snapshot dropssharedStorage. - Clients:
StorageViewperStorageNet(Rows+OnListChanged→ UI grid);InventoryTransferredis the feedback edge (flyout, sound) and may arrive one tick before the row delta — the grid follows the row, the flyout the outcome. - Equipment — two slots outside the bag, two commands, one outcome: host
CarryInventory.Equipment[2](Weapon,Armor; the tagged stack itself, moved out of the bag), persisted incarries.equipment.EquipCommand(slot, tag): stack in owner's CARRY ∧GearDefmatches slot → remove from bag (frees a bag slot — user decision), previous occupant goes into the freed slot,SetEquipped,Persist,Broadcast(EquipmentChanged).UnequipCommand(slot): slot occupied ∧CanAdd→ inverse; a bare unequip into a full bag is an honest no-op (BagFull); swaps always work. Other players' equipment is not visible at MVP. - Wire rows are unmanaged:
StackNetRow(8 B),NodeNetRow(3 B) blittable,IEquatable<T>, no reference field (fenceInv_WireRowsAreUnmanaged).OutcomeEnvelopeis 128 B because ofCarrySync(≈ 90 B atbagSlots10; the range validatesbagSlots ≤ 14). - Deliberately not replicated: other players' CARRY (never on any client), enemy inventories (none),
harvestDiffs,InstanceMetadatabeyond snapshot +CraftCommittedrow, any HUD count outside these structures.
Camera (ADR-0009 §4 / core-camera epic)
ISnapSource(SnapReason { TideDeposit, DeathRespawn }) gets its real emitter:SnapSource.Snappedraised byPlayerMotor.OnSnappedon the owner — replaces core-camera story-003's fake. Hard cuts only for those two reasons (camera Rule 3); EC21 follows smoothly.
Forbidden (Core)
- Never add a second
NetworkTickSystem.Ticksubscriber (multiple_tick_subscribers) —Tickis a bareevent Actionwith no ordering guarantee. - Never model an owner→host fact as an owner-written
NetworkVariable— delta replication carries no per-tick order, and it evades the RPC reflection test. - Never add
CheckObjectVisibilityfiltering toHostCommitChannel— every client must observe it for total order. - Never let a late
StrikeRevokeundo a landed hit. The hit stands; that is the design. - Never replicate enemy HP or combat meters — only
EnemyActor.Phase(andAnimFrame,Archetype,ActorId— ADR-0012) replicates. - Never let anything but
LandLifecycleloadland/*addressables (land_load_outside_lifecycle). - (2026-09-11) Never move a body through the physics solver — no
Rigidbody2D.MovePosition/AddForce/velocity =, noNetworkRigidbody2D/NetworkRigidbody/AnticipatedNetworkTransform, noUseRigidBodyForMotion(rigidbody_driven_motion; fenceMove_NoRigidbodyMotion).KinematicMover2Dis the only position writer. - (2026-09-11) Never call
Teleport(/SetState(outsidePlayerMotor.OnSnapped; neverTeleportfrom the host on an owner-authoritative body — it throws (teleport_outside_snap_path). - (2026-09-11) Never declare
OnCollision/OnTriggerinDriftlands.Runtime.Movementor.Enemy— collision blocks, never damages; damage is commands-only (collision_callbacks_in_movement; fenceMove_NoCollisionCallbacks). - (2026-09-11) Never hand ownership to the host for a snap (
ChangeOwnershipround trips) and never use NGO'sSetStateowner-push path — the snap is an outcome with a reason, in command order, through the codec fence (ADR-0009 Alt C/H). - (2026-09-11) Never scale
moveSpeed; never add aMoveSpeedModifier|Stamina|Encumbrance|weight|penaltymember or data key (movement_speed_modifier_for_downed;Schema_NoMoveSpeedModifier). - (2026-09-11) Never derive velocity or continuity from
HostKnownacross ticks (host_known_is_not_continuous). - (2026-09-11) Never grow
SnapReasonbeyond two members (Move_SnapReasonClosed) — EC21 is not a cut. If ADR-0009 V2 fails and EC21 must join theTeleportpath, that is an ADR amendment with the user's assent (it changes the shipped camera contract). - (2026-09-11) Never replicate
DisplacementRequest, aim (at MVP), or a playerAnimFrame. - (2026-09-11) Never write
StorageNet.RowsorBaseNet.Structuresoutside their replicator/Initialise(storage_rows_written_outside_replicator); neverAdd/RemoveAtonRows(slot-indexed, fixed length). - (2026-09-11) Never predict a harvest yield, a craft, a placement, or the storage side of a transfer — only the owner's own transfer deltas are predicted (inventory Rule 7).
- (2026-09-11) Never let a bag-touching command mutate without a delta-bearing outcome (
bag_mutation_without_outcome). - (2026-09-11) Never put CARRY in the snapshot, and never build a client "request resync" —
CarrySyncfromSessionReadyCommand.Executeis the only seed and the only resync (ADR-0010 §2). - (2026-09-11) Never read a
NetworkVariable/StorageNetrow/ClientRegistryinside an outcome handler expecting the outcome already applied (outcome_handler_reads_variable). - (2026-09-11) Never let a gate read a
NetworkListmirror —HostContext.Structures/InventoryTxare the truth; the lists are for clients (ADR-0010 §3, 0011 §2).
Guardrails (Core)
| Budget | Value | Notes |
|---|---|---|
| Host drain | ≤ 1 ms at MVP load | 50 mixed commands, Editor profile |
| Command latency (decided 2026-09-11) | RTT + ≤ 1 tick for outcomes (≤ 20 ms at 50 Hz); RTT + ≤ 2 ticks (≤ 40 ms) for state | ADR-0008 §2 — the one-tick replication lag; no longer provisional |
HostKnown staleness (2026-09-11) | ≤ 1 RTT + 1 tick | ADR-0009 §7 V6; RangeSlackUnits 1.0 u ≈ 6 u/s × 0.17 s absorbs it |
| Combat CPU | arc test ≤ ~15 enemies/strike; Tick walks ≤ 5 players + ≤ 15 enemies | Step is struct-in/struct-out, zero alloc |
| Combat network | ≤ 3 replicated fields/player (one PlayerActorNet) + Phase/AnimFrame/Archetype/ActorId per enemy | no new RPC surface |
| Movement CPU (2026-09-11) | one shape cast per axis per moving body per rendered frame — 2 casts × ≤ 10 bodies ≪ 0.1 ms; Locomotion.Step 0 alloc | Editor profile; AllocatingGCMemory + control |
| Movement memory (2026-09-11) | RaycastHit2D[8] per mover, nothing else | — |
PlayerSnapped (2026-09-11) | 25 B, a few per hour | — |
StorageReplicator (2026-09-11) | ≤ storageSlots (40; ≤ 80) comparisons per dirty container per drain; worst case storageSlots × 8 B deltas ≪ 1 KB | host, after drain |
| Node/storage spawn payload (2026-09-11) | ≤ 320 B of rows per land/chest spawn message (40 × 8 B), asserted so growth is noticed | V4 |
CarrySync (2026-09-11) | ≤ 90 B at bagSlots 10; bagSlots ≤ 14 at the 128 B envelope | wire-validated in inventory.json |
| Client fold (2026-09-11) | one CarryFold.Apply per bag-touching outcome — µs | — |
Engine constraints (Core)
- NGO 2.x reliable RPCs are ordered per NetworkObject only — across all its methods, not globally. This is the fact that makes one channel the only route to total order.
- ⚠️ OPEN (ADR-0008 V1): which player-loop stage NGO applies incoming RPCs and
NetworkTransformstate relative toTick. Probe in NetcodeSession story 004 (NetworkUpdateLoop.RegisterNetworkUpdatemarkers, documented playtest / two standalone builds); if state applies afterTick,HostKnownis one tick staler andRangeSlackUnitsabsorbs it (0.12 u) — recorded as an ADR-0008 amendment, not a rewrite. - ⚠️ OPEN (ADR-0009 V1):
NetworkTransformState's position accessor is a member of an internal struct — its accessibility fromDriftlands.Runtimeand its space (local vs world) are unverified; fallback = the host's interpolated copy plusremoteInterpBufferticks added to the staleness bound. - ⚠️ MPPM 2.0.x is not fit for automated tests on 6.3 — netcode timing runs as documented playtests or two standalone builds under Network Simulator, never as a CI gate.
- (2026-09-11, resolved)
NetworkList<T>initial contents arrive with the spawn message (source-confirmed, ADR-0010 V1) —NodesReadyis not needed; story 1 keeps a PlayModeNodes.Count == instance.Nodes.Countassertion inOnNetworkSpawnso a future NGO change is caught loudly. - (2026-09-11)
Rigidbody2D.Caston a kinematic body excludes the body's own collider automatically; against aCompositeCollider2Dtilemap and staticBoxCollider2Dwalls it returns contacts non-allocating into a preallocated array (ADR-0009 V4 — PlayMode verifies). - (2026-09-11) A
BoxCollider2Dcreated mid-frame is not queryable by the same frame's cast untilPhysics2D.SyncTransforms()—StructureViewcalls it right after creating the collider; a companion negative shows the frame gap (ADR-0011 V3). Do not rely onPhysics2D.autoSyncTransforms. - (2026-09-11) Tunnelling is not a concern of the sweep: the cast covers the whole per-frame delta (a 100 ms hitch × 22 u/s dash = 2.2 u in one cast); terrain blockers thinner than
skinWidthare an authoring error caught by the land template validator. - Addressables build-layout path differs by sub-version (
buildlayoutvsbuildlayout.json) and is written only if Debug Build Layout is enabled — pin the sub-version or the CI gate passes vacuously (vacuous_layout_gate).
FEATURE LAYER
BaseBuilding · Crafting · EnemyAI (section new 2026-09-11 — every Feature epic was Gate-0 blocked before)
Required
Structure registry (ADR-0011)
- Truth:
StructureRegistry : IStructureRegistry, IReconcilable(Runtime.Build) — the permanentstructuressave table + the host-monotonicinstanceIdcounter (persisted).IStructureRegistry.Add/Removeare called only fromPlaceStructureCommand.Execute,DemolishCommand.ExecuteandStructureRegistry.Deserialize(fenceBld_RegistryWrittenOnlyByCommands).HostContext.Structuresis this object; every F1/F2 conjunct re-samples it at commit (Rule 13).IStructureRegistry=All,AnyCovers(Cell),TryGet(uint, out StructureInstance),Add(StructureId, Cell) → uint,Remove(uint). - Mirror:
BaseNet.Structures : NetworkList<StructureNetRow{uint InstanceId, ushort StructureIndex, short X, short Y, byte W, byte H}>(14 B, unmanaged,IEquatable; fenceBld_RowIsUnmanaged) on the in-scene, always-spawnedBaseNet— its ownNetworkObject, neverHostCommitChannel's.RegistryReplicator(host) is its only runtime writer: once after everyDrain(),Structures.Add(row)per new instance,Structures.Remove(row)by value per removed one; list order is placement order and carries no meaning (fenceBld_ListSingleWriter:Structures.Add(/.Remove(/.Clear(/Structures[only inRegistryReplicatorandBaseNet.Initialise). A mirror, not host-rail state: no gate reads it, no table holds it. - Boot: after
Save.Load()reconcilesstructures(relics kept),BaseNet.Structuresis filled from the registry beforeStartHost—Initialisepopulates the backing list directly (not a replicated write) and throws ifNetworkManager.Singleton.IsListening; every storage row'sStorageNetis spawned with rows pre-filled. Late join: the joiner receivesBaseNetwith the full list at scene synchronisation; the snapshot carries nostructurestable; a placement in flight during the join is an ordinary later delta. StructureIndexTable(Runtime): ordinal-sortedIGameData.Structureskeys →ushort StructureIndex; astructureIdabsent from current data (relic) →0xFFFF(fenceBld_StructureIndexIsSortedOrdinal); hash folded intodataHashafter items.X, Y= the footprint's anchor cell (min corner);W, H= the footprint as it was when the row was written (def at placement; save column on load) — so a relic keeps its silhouette, collision,noOverlapandFindCorrectionCellfootprint after its def is gone (user decision; not narrowed to 1×1).ClientRegistry(Runtime.Build, one per machine including the host's own process — no "host instantiates directly" branch): subscribes toStructures.OnListChanged; onAddcreates aStructureView— a plain non-networkedGameObjectatCell.Center(X, Y)with a staticBoxCollider2Don layerStructuresizedFootprint × 1 u(offset to the footprint centre),isTrigger = false, noRigidbody2D, plusSpriteRenderer(def sprite; relic → fallback visual at the row's ownW × H), thenPhysics2D.SyncTransforms()so the sweep sees it this frame; onRemove(keyed exclusively onValue.InstanceId—Indexis unset on that event) destroys the view (fencesBld_NoIndexOnRemove,Bld_ViewPerRow). ExposesIStructureRegistry's read half (All,AnyCovers,TryGet;Add/Removethrow) for the ghost's provisional F1 and advisorystationPresent*— display only, never gating; a relic surfaces asStructureInstance{Structure = StructureId.Relic}(AnyCoversuses the row'sW × H;StationPresencenever matches it).- Instant blocking (AC-27): the collider exists the frame the
Addevent fires; the nextSweep.Moveon every machine stops at it. Outcome vs row:StructurePlacedmay arrive one tick before the row — presenters act on the outcome, movement on the collider. - Registry and colliders are never compared: every host gate reads
HostContext.Structures(never a collider); every sweep reads colliders (never the registry); the sub-tick window on the host where the registry leads the colliders is safe because nothing reads both (registry_and_colliders_never_compared; review fenceBld_RegistryAndCollidersNeverComparedoverRuntime.Build+Runtime.Enemy). - Chests are a registry row AND a
StorageNet, bound lazily: both are deferred effects of one drain on two objects with no ordering relation.StructureViewfor a storage row looks upStorageNetbyStructureInstance(a static dictionaryStorageNetregisters into onOnNetworkSpawn) or subscribes toStorageNet.Spawned;StorageViewbinds the other way; either arrival order works (V4). Demolish removes the row andQueueDespawns the object in the same drain; a client handles either order. A client with the chest panel open closes it on the row'sRemoveevent (StructureRemoved(InstanceId)→PanelManager) — keyed on the row, never the outcome.ContainerId = "s" + InstanceId. - Outcomes:
StructurePlaced{uint Instance, ushort StructureIndex, short X, short Y, PlayerKey Placer}(≈ 25 B) andStructureDemolished{…, PlayerKey Demolisher, bool RefundIsZero}(≈ 26 B) — both name the actor soCarryFoldcan apply cost/refund from data; the row carries no actor (AC-24 anonymity is a registry fact).CommandRejectedreasons in F1 gate order:TooFar → OutOfBounds → Overlap → Occupied → NoWayOut → BadTerrain → SocketExclusion → NotEnoughMaterials; demolish:AlreadyGone,ChestNotEmpty,NoRoomForRefund. - EC21 trigger (write mode is ADR-0009's): on the list
Addevent the localClientRegistrychecks whether the local player's cell is inside the new footprint; if soRules.Build.FindCorrectionCell(seed, terrain, registry, placeable)(pure, reads no player positions) →PlayerMotor.CorrectTo(cell.Center);null= EC22 (socially resolved). Only the owner corrects itself. Three pins: re-check immediately before the write; at most one correction in flight, re-evaluated fresh at the post-correction cell (a second wall queues one re-check); N, E, S, W order is the binding tie-break (fenceBld_CorrectionOrderIsNESWagainst the GDD fixtures). - **
stationPresent*— two contexts, one function:Rules.Build.StationPresence.Exact/AtLeast(tier, asker, registry, defs, stationRange)— pure. Host:StationPresence : IStationPresenceProvideroverHostContext.Structuresat the commit instant insideCraftCommitCommand.Execute, never cached;asker = ctx.Positions.HostKnown(OwnerClientId);RangeSlackUnitsdoes NOT apply** —stationRangeis a design knob, not an anti-cheat reach gate. Client: the same function overClientRegistryfor greying/hints only. Exactly two predicates (AC-44). StructureInstancestays exactly three fields (InstanceId,Structure,Position— AC-24, fencedSchema_StructureInstance); the wire row is a different type allowed its extra fields; the footprint lives in a save-table column, never on the instance.structures.json/StructureDef/BuildConfigget their fields in the BaseBuilding Data story (Rule 1 + knobs:placementReach,socketNoBuildRadius,perimeterSegments;stationRangeis crafting's); joinsRequiredFiles+ bothTestJsonfixtures; the placeableRegionmust fitshort(Bld_RegionFitsShort).
Crafting (ADR-0008 §5 / ADR-0010 §1 / ADR-0011 §7)
CraftCommitcommand (4+4 B:RecipeId,StationInstance?) →CraftCommitted{Player, RecipeId, InstanceId, mark row}outcome — the registered append-only exception: it carries the newInstanceMetadatarow, applied insert-if-absent byInstanceId;InstanceMetadataotherwise arrives only in the snapshot.instanceIdis issued host-side, monotonic from 1, rides stacks as the decimal-stringinstanceTag, crosses the wire asuintviaTagCodec; nothing but crafting creates a tag.- The craft stance/FX is client-predicted; the commit is the host's; the owner's bag delta is the fold over
CraftCommitted+IGameData.Recipes(data, identical everywhere). - Station gate: host-side
StationPresenceat the commit instant overHostContext.Structures(above); the client's advisory copy never pre-validates in place of the host (AC-39b).
Enemy simulation (ADR-0012)
- Identity:
EnemyActorId(SocketId Socket, ushort Index)—Indexis the enemy's position in the docking's composition list (0..n−1), identical on every machine and reload.Data.EnemyId= archetype key.EnemyIndexTable(ordinal-sortedIGameData.Enemieskeys →ushort Archetype) is appended todataHashafter structures. - Data:
enemies.json→EnemyDef{Id, Tier, MoveSpeed, PatrolSpeed, PreferredRange (0 = melee), BackpedalsWhenClosed, Interruptible, Attacks[]}withAttackDef{AttackId, TelegraphMs ≥ 400, ActiveMs, RecoveryMs, Shape (arc | thrown-point | slam-circle), Damage, Knockback{Distance, Secs}, LungeDistance, Uninterruptible}; HP/damage bands stay incombat.json.enemy-ai.json→AiConfig{aggroRadius 6 (4–10), leashRadius 14 (10–20), deAggroSecs 6 (3–12), separationRadius 1.2 (0.8–2.0), separationStrength, separationWhileAttacking (0), maxUninterruptibleTelegraphsPerTarget 1 (fixed), guardDensityByBand {shore 0–1, mid 1–2, deep 2–3} (monotonic), deathLingerSecs, noProgressEpsilonUnits}. Rusher, Spacer and Bulwark are three parameter sets of one machine, not three classes. - Spawn contract — deterministic composition, one
NetworkObjectper enemy: onLandActivated(tick step 1)EnemySpawner : IEnemySpawnerrunsRules.Enemy.Composition.Choose(template, variationSeed, spawnPoints, guardDensityByBand, allowedByBand)— pure, seeded, byte-identical everywhere (fenceAi_CompositionIsPure: 100 runs identical,seed + 1differs) — and buffersctx.QueueSpawn(SpawnRequest.Enemy(actorId, archetype, cell, facing))per spawn inIndexorder; the phase-4 flush instantiates the prefab atSpawnPoint.Cell.Center, setsEnemyNet{ActorId, Archetype, Phase = Dormant, AnimFrame = 0}beforeSpawn(), spawns, and callsCombatState.AddEnemy(actorId, archetype). Per point:k ∈ guardDensityByBand[band]enemies, archetype fromTemplateDef.GuardArchetypesByBand, facing, 0–2-point patrol offset. ≈ 10 enemies per land at MVP; oneCreateObjectMessageper object per client (V1). - No respawn, ephemeral: the spawner runs once per activation; death removes row and object for the docking's remainder; a reload re-derives the composition and every enemy returns (accepted). Nothing in
Runtime.EnemyimplementsISaveable/IReconcilable(fenceAi_NoEnemySaveTable). - Teardown order:
EvictionExpired(socket)→EnemySpawnerdespawns every enemy on the socket (Despawn(destroy: true)) before theLandNetdespawn and drops pending lingers;LandTornDown→CombatState.RemoveEnemiesOn(socket). A client handles either arrival order (the view's parent is notLandNet). (ADR-0012'sEnemySpawnerKey-Interfaces comment originally saidLandTornDown; corrected 2026-09-11 — §2's TD C6 ruling is binding, sinceLandTornDownfires after theLandNetdespawn.) - Death: combat sets
Phase = Dead; the brain entersDead, stops steering; afterdeathLingerSecsthe spawnerDespawn(destroy: true)s and combat removes the row. The linger is host-timed by holding the object alive —Despawnsends the destroy immediately; clients destroy on receipt; clients observe the kill asSwingLanded{Lethal}(edge) thenPhase = Dead(state, one tick later). - The machine —
Rules.Enemy.EnemyBrain.Step(BrainState, Senses, EnemyDef, AiConfig, nowTick, tickDt) → (BrainState′, BrainActions), pure. ModesDormant | Aggro | Return | Dead.Dormant: patrol post ↔PatrolPathatPatrolSpeed; aggro on a living non-downed player withinaggroRadiuson this land, or onWasHitSinceLastTick(always).Aggro: target = nearest such player at aggro time, re-targeted only when the target dies/leaves the land; approach (Spacer holdsPreferredRangeand backpedals when closed on; Bulwark advances slowly) ⇄ attack when in range ∧CombatPhase == Idle∧ cooldown elapsed ∧ the cap allows →EnterTelegraph(attackId); during Telegraph/Active/RecoveryDesiredVelocity = 0except a Rusher's authoredLungeDistanceasDisplace(Dash-kind)onTelegraph → Active. De-aggro whendist(target, GuardPost) > leashRadius∨ target not on this land (shore threshold absolute,Senses.TargetOnLand) ∨ no progress fordeAggroSecs(LastDistToTargetnot reduced bynoProgressEpsilonUnits— the cliff case) →Return(towardGuardPostatPatrolSpeed;LeashReset = trueonce, on entry) →Dormantat the post. Re-aggro on damage overrides every non-Deadmode, includingReturn, targeting the attacker, evaluated before the de-aggro tests — a damaged enemy can neverLeashResetin the tick after it was hit (fenceAi_DeAggroResets). - The brain never reads or writes HP or the meter; interruptibility is combat's (
IEnemyCombat.Interruptible); a hit during a Rusher's Telegraph shows up asCombatPhase → Stagger. - Density cap is AI-side and counts intent:
AiWorld.UninterruptibleTelegraphsOn(target)= enemies withBrainState.Target == target, anUninterruptibleattack pending/current, andCombatPhase == Telegraph; a Spacer/Bulwark at the cap holds range and retries next tick; Rushers exempt. It countsBrainState.Target, not resolver coverage — a player inside a second enemy's shape can take two uninterruptible hits (accepted at MVP; revisit on CC-lock reports) (fenceAi_DensityCapHolds). - Movement — steering through the shared mover, no pathfinding, no colliders:
EnemyMotor(host, one per enemy,enabledonly on the server) each rendered frame takes the last tick'sDesiredVelocity+Rules.Enemy.Steering.Separation(self, neighbours within separationRadius, cfg, selfIndex)(inverse-distance push from every other living enemy on the land) and callsKinematicMover2D.Drive(velocity, Normal, dt); knockback arrives viaIDisplacementSink.Displace(Knockback). Attacking enemies hold their ground (separationWhileAttacking = 0). Coincident-start tiebreak: withincoincidentEpsilon0.01 u the separation direction isSteering.TiebreakDirection(Index)= unit vector atIndex × 137.5°— deterministic, distinct per index, never random (fenceAi_TiebreakIsDeterministic: 16 indices → 16 distinct directions; noRandominRules.Enemy/Runtime.Enemy). Straight-line seek only; a cliff is thedeAggroSecscase (V5).GuardPost= spawn cell centre; the shore threshold isLandInstance's cell bounds viaILandBounds(pure) — the rule is the bound, not the collider. - Replication:
EnemyTransform(Server authority, X/Y,Interpolateon clients,PositionThreshold 0.02, unreliable deltas, tick rate, global buffer) ·EnemyNet { Phase : NetworkVariable<byte>; AnimFrame : NetworkVariable<byte> (traits 0.05 s, written by the hostEnemyAnimatorfrom phase fraction × flipbook length); Archetype : NetworkVariable<ushort>; ActorId : NetworkVariable<EnemyActorIdNet{byte Socket, ushort Index}> }—Archetype/ActorIdset beforeSpawn()(fenceAi_WireStructsUnmanaged). Never HP, meter, immunity, target or brain state. Cadence is full tick rate — a reduced enemy cadence is deliberately not built;PositionThresholdis the knob if bandwidth ever measures. - Host step placement:
EnemyAI.Tick(ctx)= host step 3 — for each living enemy on a docked land: buildSenses(players fromctx.Positions.All()mapped to keys viaSessionState, phases fromICombatState.Enemy(actorId), cap count fromAiWorld,TargetOnLand),EnemyBrain.Step, applyBrainActions(EnterTelegraph/LeashResetthroughIEnemyCombat;DesiredVelocity/DisplaceintoEnemyMotor).Sensesis a struct over pooled lists — 0 B/tick. The AI never callsEnemyAttackResolver— combat fires it from step 2'sTelegraph → Active. - Telegraph timing:
TelegraphMs ≥ 400= ≥ 20 ticks at 50 Hz, exact host-side (tick-based phase timer); the remote read (combat AC-25, ≥ 330 ms visible at 140 ms RTT) is a playtest measurement — the fix for a miss is telegraph length/dash tuning in data, never lag compensation. Ai_NoClientAi:EnemyMotor/EnemyAI/EnemySpawnerareIsServer-guarded (PlayMode client-mode fixture: no tick runs);EnemyViewreferences no brain/motor type (client_side_enemy_ai).- Party scaling lever pre-armed:
EnemyScalingContext.Emptypassed through;guardDensityByBandis the lever (Rule 11).
Forbidden (Feature)
- Never let a command's
ExecutereadClientRegistry—Rulescannot name it andHostContextholds noClientRegistry(client_registry_gating_a_commit; fenceBld_ClientRegistryIsAdvisory). - Never derive a blocker from
HostContext.Structuresor a gate from a collider (registry_and_colliders_never_compared). - Never read
NetworkListEvent.Indexon aRemoveevent — key onValue.InstanceId(list_index_on_remove). - Never write
BaseNet.StructuresoutsideRegistryReplicator/BaseNet.Initialise; never callInitialiseon a listeningNetworkManager. - Never trigger EC21 from
StructurePlaced— the outcome can precede the collider by a tick; correcting first could move the player into the wall (ADR-0011 Alt F). - Never put the footprint (or a builder, or a cost snapshot) on
StructureInstance(AC-24); never narrow a relic to1×1. - Never widen
stationRangebyRangeSlackUnits; never cachestationPresenthost-side; never let the client copy gate a commit (AC-39b). - Never give a structure its own
NetworkObject(Alt B) or a wallTilemap/CompositeCollider2D(Alt D — the recorded optimisation if box count ever measures). - Never run enemy AI, prediction, or a gating timer on a client (
client_side_enemy_ai;Ai_NoClientAi). - Never query
Physics2D/Collider2D/Rigidbody2D.CastfromRuntime.Enemy— movement only viaKinematicMover2D(collider_queries_in_enemy_runtime;Ai_NoColliderQueries); never pathfind (A*/navmesh — Alt E). - Never call anything on the combat surface but
EnterTelegraphandLeashResetfrom AI code (Ai_BoundaryIsTwoCalls); never let the brain read/write HP or the meter; never let combat read the AI's aggro state (Alt F). - Never use
System.Random/UnityEngine.RandominRules.Enemy/Runtime.Enemy(random_in_enemy_rules) — composition and the tiebreak are seeded/deterministic. - Never save anything about enemies (
enemy_save_table); never respawn within a docking. - Never write three archetype classes or a behaviour tree — a new mode is an ADR amendment, a new parameter is data (Alts C/D).
- Never build a per-enemy transform cadence or throttle (Alt G) —
PositionThresholdis the only knob. - Never let
EnemySpawnermutate a liveEnemyActorfield — its phase-4 site is row creation/destruction only (ADR-0012 C2). - Never derive telegraph VFX or pose from local animation timers — drive them from the replicated
Phasechange (TR-combat-007).
Guardrails (Feature)
| Budget | Value | Notes |
|---|---|---|
EnemyAI.Tick | ≤ 0.3 ms per tick for 20 enemies, 0 B/tick steady state | Editor profile; Senses over pooled lists; AllocatingGCMemory + control |
| Enemy movement | one sweep per enemy per rendered frame (ADR-0009's cost) | — |
| Enemy network | ≈ 14 KB/s down for 10 moving enemies at 50 Hz + ≤ 1 KB/s Phase/AnimFrame; AnimFrame ≤ 20 Hz × ~5 B | inside ADR-0008 §8's 20 KB/s guardrail |
| Activation spawn burst | ≤ ~10 CreateObjectMessages in one tick, each ≤ MaxPayloadSize; message count == spawn count | V1; if it ever matters, spread over ticks with no wire change |
| Telegraph floor | ≥ 400 ms = ≥ 20 ticks, exact host-side | load-rejected below 400 |
RegistryReplicator | O(changes) per drain | host, after drain |
ClientRegistry | O(1) per event; AnyCovers O(rows ≤ ~100) per ghost frame | trivial at EC18's scale |
| Registry wire | 14 B per placement/demolish delta; ≤ ~1.4 KB scene-sync for the whole base (~100 rows) | asserted (V5) so growth past EC18 is noticed |
| Structure memory | one GameObject + BoxCollider2D per structure per machine | Alt D is the recorded swap if it measures |
| BB ghost preview | 0 B per-frame allocation in Rules.Build | Profiler (carried from Foundation) |
Engine constraints (Feature)
NetworkList.Remove(T)never assignsIndexand shifts later elements (src 498–522) — the whole reason clients key onInstanceId.- In-scene
NetworkObjectlists reach a late joiner only if filled beforeStartHost(CHANGELOG #3177);NetworkConfig.EnableSceneManagementstaystrue(default). Physics2D.SyncTransforms()after creating a collider mid-frame or the same frame'sRigidbody2D.Castdoes not see it (ADR-0011 V3).NetworkObject.Despawn(true)sends the destroy immediately — a death linger must hold the object alive host-side;DeferredDespawnTickis not used.MinSecondsBetweenUpdatesgates sends only — the host may writeAnimFrameevery frame; a client sees ≥ 15 distinct frames/s over a 1 s flipbook (V2).- ⚠️ Steering oscillation between seek and separation at
PreferredRange(Spacer jitter) is a tuning risk — dead-band onPreferredRangeinEnemyBrain, PlayMode fixture with two Spacers on one target (ADR-0012 Risks).
PRESENTATION LAYER
UIHUD · TideTint · FeedbackPresenters · EnemyView
Required
Tide tint (ADR-0013) — the rule that has already been wrong once
_TideTintand_TideAmountmust be declared at Shader Graph blackboardScope = Global.- ⚠️
Show In Inspectoris NOT that control. Disabling it only adds[HideInInspector]— the property is still in the material's property block and still shadows the global;Shader.SetGlobalbecomes a silent no-op. OnlyScope = Globalremoves it from the block (review E14)*. - The editor test asserts absence:
shader.FindPropertyIndex("_TideTint") == -1(and_TideAmount), as instance methods on the loadedShader. Never assertGetPropertyFlags(...) == HideInInspector(review E15). TideTintDriver(inPresentation) writes both globals exactly twice per frame, reading onlyITideClock.TintFloats(0). No tide math. No caching across frames.DriftLayeris exclusion by construction: excluded sprites use a different shader with no tint inputs, never a runtime branch.- On
LandTornDown(socket)the driver writes_TideAmount = 0— a global retains its last value. - Violet is held at 1.0 through the whole
EvictionGracewindow — no fade, no early neutral. - The BB ghost's dashed state is pre-authored sprite frames in the home atlas.
- The reach grid is one procedural mesh, one unlit material (1 draw call), never a quad per cell.
N > maxSocketsSupported(currently 1) fails boot loudly — never a runtime warning.- SRP-batcher compatibility is asserted structurally:
ShaderUtil.GetSRPBatcherCompatibilityCode(DriftLayerSprite) == 0after one rendered frame, beside a deliberately incompatible control shader.UnityStats.batchesis not a meter (ADR-0013 V2, 2026-09-07).
UI (ADR-0014)
- UI Toolkit for all screen-space UI; sprites, never UGUI, for all world-space UI.
- No uGUI
EventSystemin any scene, ever (ui_event_system_present). - World-space text uses
TextMeshPro(mesh) — neverTextMeshProUGUI. RebindingOperation.Dispose()on all four exit paths — success, cancel, timeout, screen close (V4).- One
IPanelManagerowns which screen is open; it asks theInputAdapterand never touchesInputSystemdirectly. (2026-09-11) It closes any panel bound to a structure instance on that row'sRemoveevent (StructureRemoved(InstanceId)fromStructureViewteardown) — keyed on the row, never onStructureDemolished(ADR-0011 §4). UiCapturedis set from anUpdateafter-1000, so capture applies from the next frame.hudOpacityis per-element via.hud-dimmable.ReasonSelector.Steplives inRules, not Presentation (formula_in_runtime_assembly). (2026-09-11) The ghost's reason string on a refused commit comes fromCommandRejected.ReasonCodein F1 gate order (ADR-0011 §5).- Hysteresis: a string persists ≥ 250 ms; a different first-failing gate replaces it only after 2 consecutive frames. The ghost outline is never hysteretic.
settings.json: atomic writer, no backups, noF_FULLFSYNC, never inside a tick. 14 keys, never in the world save.- Settings failures are specific: missing file → silent defaults; malformed → defaults + loud log +
settings.bad.json+ notice; out-of-range → clamp and log by name; missing key → default and log by name. Application.targetFrameRate = 60is pinned regardless of vsync.- (2026-09-11) Title screen host/join consumes
ISessionLauncher(HostAsync → join code,JoinAsync(code)); a client'sOnConnectionEvent(Disconnected)routes to the title screen with the reason string (ConfigVersion,"bad key","key in use", host left); the HUD debug overlay showsSessionState.DroppedByRulecounters andCurrentRttMs(ADR-0008 §1/§7). The generic join-code model is preserved — no Steam primitives. - (2026-09-11) Inventory/storage screens bind to
ICarryView(Visible) andStorageView(rows); the build picker/placement screens bind toClientRegistry+StructureIndexTable; HUD affordability reads the fold'sConfirmed(ADR-0010 §2, 0011 Enables).
Feedback presenters and views (ADR-0002 §7 / ADR-0008 §2 / ADR-0012 §5) (new 2026-09-11)
- Presenters act on the outcome's own payload (
DamageTaken,Yield,Instance,ReasonCode, …) and treat replicated variables/rows as state that will catch up one tick later (outcome_handler_reads_variable). The flyout followsInventoryTransferred/HarvestResolved; the grid follows the row; the wall thunk followsStructurePlaced; the wall itself follows the row. EnemyView(every machine): flipbook byArchetype × Phase × AnimFrame; telegraph pose/rim onPhase → Telegraph(never re-derived from local animation — TR-combat-007); Stagger White onSwingLanded{Victim == ActorId, StaggerFired}; death flipbook onPhase → Dead; facing from interpolated velocity. No client AI, no prediction, no local timers that gate anything — a client never decides a phase.EnemyViewreferences no brain/motor type.- Remote player bodies face their last non-zero movement direction from interpolated velocity; the owner faces its aim; facing =
SpriteRenderer.flipX, never a negativelocalScale.x(ADR-0009 §8). CommandRejectedun-predicts the owner's own FX only (whiff, ghost reason, dropped pending bag delta) — the world shows nothing for a rejected command.NodeViewderives the crack stage fromSwingsRemaining / swingsTotalon the list'sOnListChangedfor its index; the yield flash ridesHarvestResolved(host-confirmed).StructureViewsprite: def sprite, or the generic relic visual at the row'sW × HforStructureIndex == 0xFFFF. No gear visuals on remote bodies at MVP.
Forbidden (Presentation)
exposed_shader_global_read— never drive aPer Materialproperty withSetGlobal*.mpb_on_driftlayer_sprite— noMaterialPropertyBlockanywhere in the DriftLayer path.baked_tide_state_variant— no_low/_mid/_highsprite variants.non_render_graph_pass— no pre-6.3SetupRenderPassesshape. ⚠️modules/rendering.mdstill contains that deprecated signature — do not copy from it.- Never build the
N > 1per-sprite-phase route at MVP. scheme_gating_gameplay—InputSchemeis presentation-only (glyphs), never a gameplay branch.input_system_outside_adapter— onlyInputAdapterand rebind types referenceUnityEngine.InputSystem.input_edges_in_fixed_update— one frame in six has zeroFixedUpdatecalls at 60 Hz render / 50 Hz physics.- Never add a
Rotateaction or a Build-map move/relocate verb. - Never add UGUI world-space canvases for flyouts.
- Never implement
uiScaleviareferenceResolution— usePanelSettings.scale. - Never expose a per-player aggregate of craft/build/harvest tallies anywhere in
Driftlands.*. - Never give the fatal-config screen USS or a theme.
- (2026-09-11) Never read a
NetworkVariable.Value, aStorageNetrow orClientRegistryinside anOutcomeRpchandler expecting the outcome already applied (outcome_handler_reads_variable). - (2026-09-11) Never drive a telegraph, stagger or death visual from a local timer or animation state — only from the replicated
Phasechange or theSwingLandededge (client_side_enemy_ai). - (2026-09-11) Never show another player's bag, or predict a harvest/craft/placement bag effect in the HUD —
Visible=Confirmed+ own pending transfers only. - (2026-09-11) Never close a chest panel on
StructureDemolished— close on the row'sRemove(the outcome can precede the row). - (2026-09-11) Never design title-screen host/join around Steam lobbies, rich presence or friend invites —
ISessionLauncher's join code is the abstraction (QQ-04).
Guardrails (Presentation)
| Budget | Value | How it must be measured |
|---|---|---|
| BB build overlay | ≤ +2 draw calls in the editor (grid ≤ 1 + ghost ≤ 1), build mode only; ghost-0 is a built-player claim, deferred | Marginal delta via UnityStats.drawCalls — before vs. after entering build mode with the ghost dashed. Never "total < 100". spritePackerMode = 3 means no atlas exists in Play Mode (ADR-0013 V4, 2026-09-07). |
| World-space UI | ≈ +2 draw calls, fixed (UI atlas + shared TMP font atlas) | Marginal delta across the full bounded population (4 party rows, 8 flyouts, 4 edge glyphs, 1 ring, 1 ping). The delta must not scale with element count. |
ui-worldspace | ≤ 0.2 ms/frame | hud.md's own performance AC |
ui-panels | 0.0 ms | Retained mode, update-on-change. A future need for a measurable slice violates this invariant — flag it, never absorb it. |
| Tide tint CPU | 2 SetGlobal* calls/frame, zero alloc | Rides ADR-0001's ≤ 50 µs / 0-byte gate. |
| SRP batching (V2) | GetSRPBatcherCompatibilityCode(DriftLayerSprite) == 0, control ≠ 0 | Automatable, CI-safe. Never count UnityStats.batches (ADR-0013 V2, 2026-09-07). |
| Structure sprites (2026-09-11) | one SpriteRenderer per StructureView (≤ ~100), home atlas | batching claim is a built-player Frame Debugger check (ADR-0013 V8 rule) |
| Enemy flipbooks (2026-09-11) | one SpriteRenderer per enemy (≤ ~10 per land), frame from AnimFrame — zero per-frame allocation | Profiler |
Engine constraints (Presentation)
- Shader property precedence is
per-instance → material → global— the global applies only if the material does not define the property. Spike V1 before building anything on it. Shader.SetGlobalColorArraydoes not exist on 6.3 — only Float/Vector/Matrix array setters.- Shader Graph 17.x has no array property type — an
N > 1global array needs a Custom Function node with hand-written HLSL. - ⚠️
SpriteRendererexposes no vertex-stream API — theN > 1per-sprite route is likely unavailable as designed. - UI Toolkit's built-in scene-independent runtime event system consumes the project-wide
UImap with noEventSystem.PanelEventHandler/PanelRaycasterappear only when anEventSystemexists, and then deactivate the built-in path. - UI Toolkit matches by action role via Project Settings → Input System Package → UI Actions, not by the map's literal name — safe here only because this project explicitly assigns that reference.
- ⚠️ UNVERIFIED: whether
PanelSettings.scalere-lays-out live at runtime. Spike first; the fallback is boot-time application with a "restart to apply" notice. - 6.3's USS parser is strict — an invalid selector blocks asset import. CI asserts every
.ussunderAssets/UI/imports clean. - Input System 1.20 removed per-control
pressPointon sticks — action-level press APIs follow an explicitPressinteraction's threshold. EditorSettings.spritePackerMode = 3packs sprite atlases at build time only — Play Mode never renders from aSpriteAtlas; every atlas-batching claim is verified only on a built player; an EditMode test asserts the setting (ADR-0013 V8, 2026-09-07).- (2026-09-11)
NetworkTransforminterpolation on non-authority instances is the only smoothing a remote body gets —Interpolate = true,Lerp, global buffer; a presenter never adds its own smoothing, extrapolation or dead-reckoning on top (TR-move-004).
Implementation status (2026-09-11 — per-layer fractions from requirements-traceability.md; suite counts from production/session-state/active.md; Gate 0 from production/playtests/gate-0-2026-09-11/)
| Item | State |
|---|---|
UnityProject/ on 6000.3.18f1 | ✅ ProjectVersion.txt corroborates the CI pin; package pins match every ADR assumption (Input System 1.20.0, Addressables 2.9.1, URP/SG 17.3.0, Newtonsoft) |
Assemblies Tide / Data / Rules / Runtime / Presentation / Editor + two test assemblies | ✅ all exist; every ADR-0003 fence green and mutation-verified; HostContext pinned at 18 members until ADR-0010's story moves it to 19 |
| Foundation epics (bootstrap, tide, save, game-data, land-lifecycle, input, boot-scene) | ✅ Done except tide story 010 (t replication) and land story 009 (LandNet NGO) — both flipped Blocked → Ready 2026-09-11; the UNITY_LICENSE/hardware items remain human-only |
| Presentation epics (tide-tint, ui-hud-shell, feedback-presenters, boot-scene) | ✅ Done — 6/8 full-chain |
Core: core-inventory-carry 5/5, core-harvesting 8/8, core-camera | ✅ Done — the pure halves; the replication halves (ADR-0010) and PlayerMovement (ADR-0009) are unblocked, not yet storied |
| EditMode / PlayMode suites | ✅ 971/968/0/3 (3 pre-existing [Explicit] fixture generators) · 112/96/0/16 (16 GPU-guarded skips) — 2026-09-11 |
| Gate 0 | ✅ PASSED — confirmed GO 2026-09-11 (two machines, Relay europe-west4, RTT ≈ 150–280 ms; 0 client warps; own actions instant; hits landed where seen). Register item: remote-stutter interpolation tuning (ADR-0009 §6 order). |
| NGO 2.13.2 / UTP 2.7.3 / Multiplayer Services 2.2.1 / Tools 2.2.9 | ⏳ decided and source-verified (ADR-0008), not yet in UnityProject/Packages/manifest.json — NetcodeSession story 001 adds them; until then every Net_* fence is unrunnable and Runtime references none of the Unity.Netcode/Services assemblies |
| ADR-0008–0012 | ✅ all Accepted 2026-09-11 without qualification; every sibling ADR/GDD/quick-spec/registry entry amended in the same commits; /architecture-review owed in a fresh session (never the authoring session) |
| Epics unblocked 2026-09-11, in the architecture's suggested order | NetcodeSession → PlayerMovement → Inventory replication (core-harvesting/core-inventory second halves) → Combat → Crafting / BaseBuilding / EnemyAI |
| Review follow-up stories (2026-09-07 review) | ❌ still open: HarvestConfig → double, NextEvictionInstant onto IPreloadSchedule, spritePackerMode assertion, HostCommitQueue header, E10 banners, ADR-0015 ms budget (see docs/tech-debt-register.md); ReconcileFinding migration ✅ (review-fixes story-001, 2026-09-08) |
| Register items from Gate 0 | interpolation tuning (try RemoteInterpBuffer 1→2, then SmoothDampening, players win; measure at the next two-machine session); RttLogger CSV corruption when two instances share one file (kit-only) |