DRIFTLANDS co-op survival on drifting tides — studio progress

ADR-0007: Input Action Map — The Project-Wide .inputactions Asset, Three Maps, Two Schemes, the InputFrame Snapshot, and Rebind Persistence

Status

Accepted

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

Date

2026-08-28

Engine Compatibility

Field Value
EngineUnity 6.3 LTS (6000.3) · Input System package (com.unity.inputsystem 1.20.x — specialist-verified current for 6000.x, changelog through 2026-07) · C#
DomainInput
Knowledge RiskLOW for the core action/rebinding API (InputActionAsset, control schemes, WasPressedThisFrame, PerformInteractiveRebinding, Save/LoadBindingOverridesFromJson — all live-verified unchanged); MEDIUM for two Unity-6-era shifts the training era did not have: project-wide actions (InputSystem.actions, auto-instantiated and auto-enabled) and UI Toolkit consuming the project-wide UI map directly with no InputSystemUIInputModule/EventSystem (2023.2+). Both are designed around below, not assumed.
References Consulteddocs/engine-reference/unity/VERSION.md, modules/input.md, modules/ui.md, deprecated-apis.md, current-best-practices.md (Awaitable); live: Input System package docs about-project-wide-actions.md, understand-ui-compatibility.md, configure-ui-input-action-map.md, ui-action-map-reference.md, polling-actions.md, timing-optimize-dynamic-update.md, binding-resolution.md, save-load-rebinds.md, interactive-rebinding.md, default-value-properties.md, CHANGELOG 1.20.0; ADR-0002/0003/0004/0006
Post-Cutoff APIs UsedInputSystem.actions (project-wide actions, 1.8+); Awaitable<T> (6.x). Verified facts: (a) UI Toolkit on 2023.2+ consumes the project-wide asset's UI map via its built-in, scene-independent default runtime event system — no EventSystem GameObject and no InputSystemUIInputModule required (corrected 2026-08-28, architecture review E2: PanelEventHandler/PanelRaycaster exist only when a uGUI EventSystem is present, and then route through InputSystemUIInputModule — the opposite path; this project has no uGUI, so that path never exists); the UI map's name, action names and action types must not be changed; (b) ProcessEventsInDynamicUpdate + polling WasPressedThisFrame in Update is the documented pattern; (c) removing a device cancels any in-progress action bound to it → a Value stick action reads zero (binding-resolution docs) — the Move → 0 claim is settled; (d) 1.20.0 removed per-control pressPoint on stick controls and made action-level press APIs follow an explicit Press interaction's threshold; (e) LoadBindingOverridesFromJson removes existing overrides first by default (replace semantics — never pass false).
Verification Required(1) Generated C# wrapper compiles under 6.3's [SerializeField] fields-only rule (Migration step 1's exit criterion — owner: that story). (2) PerformInteractiveRebinding's OnComplete wraps into AwaitableCompletionSource<T> cleanly on 6.3 (first Settings-screen story, ADR-0014). (3) Binding-override JSON keying on a renamed/removed action: silently skipped vs thrown — decides whether §4's recovery is "partial apply + log" or "catch + .bad.json" (Migration step 4). (4) Setting a per-binding Press interaction threshold from data at boot (ApplyParameterOverride) — Migration step 3. (5) That referencing InputSystem.actions and never new DriftlandsControls() yields exactly one enabled instance (the double-instantiation hazard is documented as the general model, not this exact collision) — an EditMode test counts enabled Player maps.

Documents Amended by This ADR

Document Amendment
ADR-0006§1 file list gains input.json; manifest.json.files lists it; §2 step 3 Configs.{…} gains Input; stage 9 becomes "Movement / Camera / Input / Inventory / Save / Land" and gains the §5 rows + the aim-persistence schema-negative.
docs/architecture/architecture.md§2 Input row (three maps, project-wide, InputFrame); §4 IPlayerInput becomes IPlayerInput.Current : InputFrame; IAimSource retained (Movement pass-through — ruled); Required-ADR row 7.
BB AC-33unchanged — its "Build map contains no move/relocate verb" assertion is carried by this ADR's asset test.

ADR Dependencies

Field Value
Depends OnADR-0003 (Driftlands.Runtime is the only assembly referencing Unity.InputSystem; InputFrame/IPlayerInput in Rules per the IHostPositions precedent; InputConfig in Data), ADR-0006 (input.json — amended above), ADR-0004 (the atomic writer, reused for bindings.json)
EnablesADR-0009 (PlayerMovement consumes InputFrame.Move/Aim, publishes IAimSource as a pass-through), ADR-0015 (combat: Strike/Dash edges, the Revive channel via Held duration, Downed/Dead suppression inside its state machine), ADR-0011 (Build map actions, Demolish, the ghost from PointerWorld/Aim), ADR-0014 (UI map, capture flag, IRebindService, the settings screen), ADR-0008 (nothing on the wire — input never leaves the machine)
BlocksFirst playable-character story; build-mode story; rebinding-screen story
Ordering NoteArchitecture Required-ADR row 7 = ADR-0007 — the last Foundation-layer ADR. Accept after ADR-0003/0006; ADR-0009/0011/0015 read InputFrame and cannot leave Proposed before this is Accepted.

Context

Problem Statement

Architecture fixes a flat IPlayerInput and "one .inputactions, Player + Build maps, KB/M + gamepad schemes, decoupled aim, rebind persistence" — but not the asset's action list and types, how mouse position and right-stick direction become one aim vector that persists on stick release (movement Rule 3), who detects button edges (combat, harvesting and build mode all react to presses; three edge detectors is a double-fire bug), how the Build map coexists with the Player map (BB Rule 5: any non-Build action exits, move and camera stay live), where rebinds persist, or what input.json holds. Unity 6 adds a question none of the docs asked: the project-wide actions asset is auto-instantiated and auto-enabled, and UI Toolkit reads its UI map directly — so "who owns the instance" must be decided before anything else.

Constraints

Requirements

Decision

**Alternative A: Driftlands.inputactions is the project's project-wide actions asset (generated C# wrapper DriftlandsControls for typed access) with three maps — Player, Build, UI (Unity's reference names/types, consumed by UI Toolkit directly) — and two control schemes — KeyboardMouse, Gamepad. A single Runtime InputAdapter references the one instance through InputSystem.actions (never new), takes ownership of enabling/disabling the Player/Build maps, and reads every action once per frame in Update into an immutable InputFrame (in Driftlands.Rules.Input) exposed through IPlayerInput.Current; aim is resolved by one pure function with last-direction persistence; BB's build-mode routing is one pure function; binding overrides persist as JSON under persistentDataPath/Driftlands/settings/bindings.json; tunables live in input.json.**

1. The asset

Map Action Type KeyboardMouse Gamepad Notes
PlayerMoveValue/Vector2WASD (2D composite)Left stickmoveStickDeadzone
PlayerAimPointerValue/Vector2 (pass-through)Mouse position (screen)—KB&M only; world-converted by the adapter
PlayerAimStickValue/Vector2—Right stickaimStickDeadzone
PlayerStrikeButtonLMBRT — per-binding Press interaction, threshold triggerPressPoint (not the global defaultButtonPressPoint)shared by combat + harvesting
PlayerDashButtonSpaceRBi-frames on the edge
PlayerInteractButtonEWestchests, stations, pickup
PlayerReviveButton (read as Held duration)E (held, when an ally is Downed in reach)West (held)combat Rule 9 channel — the only hold; see Rule (d)
PlayerDemolishButtonRMB (outside build mode)D-pad DownBB Rule 9: instant, targeted, from Idle/Move; raises the refund-preview prompt (ruled 2026-08-28)
PlayerInventoryButtonTabNorthopens the bag panel (UI capture)
PlayerBuildToggleButtonBD-pad Upenters/exits build mode
PlayerMenuButtonEscStartpause/settings — protected
BuildConfirmButtonLMBSouthplacement commit (BB Rule 6)
BuildCancelButtonRMB / EscEastexits build mode
BuildPickPrevButtonQ (scroll down — fixed secondary)D-pad Leftstructure picker
BuildPickNextButtonR (scroll up — fixed secondary)D-pad Rightstructure picker (row split 2026-08-31 — the pair previously shared one row naming only Q / scroll; R was unbound)
UINavigate, Submit, Cancel, Point, Click, RightClick, MiddleClick, ScrollWheel, TrackedDevicePosition, TrackedDeviceOrientationUnity's reference set, names and types unchangedconsumed by UI Toolkit's built-in default runtime event system directly — no EventSystem, no InputSystemUIInputModule, no PanelEventHandler (ADR-0014 owns the panels)

Rules: (a) Strike and Confirm share LMB; Demolish and Cancel share RMB — deliberate; the adapter routes by build-mode state (§3), so one physical button is "strike/demolish" outside and "place/cancel" inside (BB's "no menu confirmation"). (b) Every Player/Build action has a binding in both schemes except the two aim sources — asserted by the EditMode asset test. (c) Player/Build are disabled by the adapter at boot (project-wide auto-enable would otherwise enable them before the adapter runs) and enabled by it; UI is enabled only while a panel is open. (d) Exactly one action is a hold: Revive. Harvesting Rule 1 and combat Rules 1–3 are press-only. The revive channel is modelled as ButtonState.HeldFor seconds read by ADR-0015 — not as a Hold interaction on the asset — so combat EC5/EC6's cancel rules stay in a pure state machine. The asset test asserts no Hold/MultiTap/SlowTap interaction exists on any binding. (e) No Rotate action — BB Rule 7 bans structure rotation at MVP and AC-2 makes the absent field a schema check; adding rotation is a BB design change. (f) The Build map contains no move/relocate verb (BB AC-33 — asset test). (g) Menu exits build mode before opening pause (BB transition table) — the player never resumes from pause into a live ghost with LMB bound to Confirm. (h) Processors on the asset are limited to deadzones and the RT Press threshold; everything else is adapter/Rules logic.

2. The snapshot

namespace Driftlands.Rules.Input          // Rules declares, Runtime implements — ADR-0003's IHostPositions precedent
{
    public readonly record struct ButtonState(bool Pressed, bool Held, bool Released, float HeldFor);  // edges are THIS-FRAME; HeldFor = seconds held (Revive channel)
    public enum InputScheme : byte { KeyboardMouse, Gamepad }

    /// One immutable snapshot per rendered frame. Every consumer in a frame reads the same instance.
    public sealed record InputFrame(
        int          FrameIndex,
        InputScheme  Scheme,               // PRESENTATION ONLY (prompt glyphs, settings list) — referenced from Driftlands.Presentation alone (analyzer test)
        Vector2      Move,                 // normalised, deadzoned; zero when no device
        Vector2      Aim,                  // unit; persists at last value when neither source is live
        bool         AimIsPointer,         // this frame's aim came from the pointer — THE discriminator for BB Rule 5's cursor-vs-stick ghost
        Vector2?     PointerWorld,         // mouse world position when present — BB ghost follows this on KB/M
        ButtonState  Strike, Dash, Interact, Revive, Demolish, Inventory, BuildToggle, Menu,
        ButtonState  Confirm, Cancel, PickPrev, PickNext,   // Build map; all-false unless build mode is active
        bool         BuildModeActive,
        bool         UiCaptured);          // a panel is open: every Player/Build edge is false EXCEPT Menu and Inventory (§3)

    public interface IPlayerInput { InputFrame Current { get; } }
}
namespace Driftlands.Data { public sealed record InputConfig(float MoveStickDeadzone, float AimStickDeadzone, float AimPointerMinDistance, float TriggerPressPoint); }  // input.json — NO aim-persistence key (schema-negative)

Vector2 is System.Numerics.Vector2 (ADR-0003). Architecture's flat IPlayerInput is superseded by IPlayerInput.Current. IAimSource is retained (ruled 2026-08-28): PlayerMovement implements AimVector => _input.Current.Aim as a pass-through, and Camera, Combat and the BB ghost keep depending on Movement — this ADR fixes where aim is computed, not who publishes it.

3. The adapter — one instance, one read point, one aim function, one routing function

InputAdapter : MonoBehaviour (Runtime; injected as IPlayerInput; one per machine; [DefaultExecutionOrder(-1000)]):

4. Rebinding and persistence

5. input.json (ADR-0006 stage 9 — amended)

Key Default Range Validation
moveStickDeadzone0.20.05–0.5in range (warning outside)
aimStickDeadzone0.250.05–0.5in range; ≥ moveStickDeadzone − 0.1
aimPointerMinDistance0.5 u0.1–2.0> 0
triggerPressPoint0.50.2–0.9in range; applied as the Press interaction threshold on Strike's RT binding at boot (Verification 4)

Schema-negative (movement Rule 3): input.json has no aimPersistsOnRelease key and no key that could disable aim persistence — persistence is AimResolver's else prev branch, not a configuration; an EditMode test asserts InputConfig declares no such field (ADR-0003 Schema_* family, the MoveSpeedModifier pattern). No hold durations exist here (reviveChannelTime is combat's, in combat.json).

Amendment to ADR-0006 (same change as Migration step 3): §1 file list gains input.json moveStickDeadzone, aimStickDeadzone, aimPointerMinDistance, triggerPressPoint, listed in manifest.json.files; §2 step 3 Configs.{…, Camera, Input, Save, …}; stage 9 title gains Input and these rows. Source assets/data/input.json, build-mirrored to StreamingAssets/data/; the data-overrides/ folder applies identically.

Architecture Diagram

 Project Settings ─► project-wide actions = Driftlands.inputactions (Player / Build / UI · KeyboardMouse / Gamepad)
                                              │  InputSystem.actions — ONE instance, Unity-owned
                                     InputAdapter (Runtime, Update @ -1000) — references, never new()s; owns Player/Build enable
                                              │  reads each action once; ScreenToWorldPoint → PointerWorld
                                     AimResolver.Resolve (Rules, pure) ◄── InputConfig (Data, input.json)
                                     BuildModeRouting.Step (Rules, pure) · UI capture · edges/HeldFor
                                              ▼
                                     InputFrame (Rules.Input, immutable)  ◄── IPlayerInput.Current
              ┌──────────────┬──────────────────┬──────────────────┬──────────────────┐
        PlayerMovement    Combat (ADR-0015)   Harvest intent    BaseBuilding (ADR-0011)   UI Toolkit panels
        (ADR-0009)        Strike/Dash/Revive  Strike + referent  Confirm/Cancel/Demolish   ◄── UI map (UI Toolkit default event system, direct)
              │ IAimSource.AimVector (pass-through, retained)
              └──► Camera (aimBiasFrac) · Combat · BB ghost (PointerWorld on KB/M, projected Aim on gamepad — AimIsPointer decides, never Scheme)
 bindings.json (persistentDataPath/Driftlands/settings) ◄──► Save/LoadBindingOverridesFromJson (replace semantics)

Key Interfaces

namespace Driftlands.Rules.Input
{
    public static class AimResolver
    {   /// Pure. One path for both schemes; returns prev when neither source is live (movement Rule 3).
        public static (Vector2 aim, bool isPointer) Resolve(Vector2 prev, Vector2? pointerWorld, Vector2 playerWorld, Vector2 stick, InputConfig cfg); }

    [Flags] public enum EdgeMask { None = 0, Strike = 1, Demolish = 2, Confirm = 4, Cancel = 8 /* … one bit per maskable action */ }
    public static class BuildModeRouting
    {   /// Pure. BB Rule 5 + transition table incl. the shared-control exception (LMB/RMB on KB/M) and the gamepad asymmetry.
        public static (bool active, EdgeMask mask, bool exitedThisFrame) Step(bool active, InputScheme scheme, RawEdges raw); }
}

namespace Driftlands.Runtime.Input
{
    public sealed class InputAdapter : MonoBehaviour, IPlayerInput
    {
        public InputFrame Current { get; private set; }
        public void SetUiCaptured(bool captured);        // ADR-0014's panel manager; takes effect next frame
        public IRebindService Rebind { get; }
    }
    public interface IRebindService
    {
        Awaitable<RebindResult> RebindAsync(string actionId, InputScheme scheme, CancellationToken ct);  // Awaitable, not Task; refuses protected actions / empty-scheme results; surfaces conflicts for swap-on-confirm
        void ResetToDefaults();
        IReadOnlyList<BindingDisplay> Display(InputScheme scheme);
    }
}

Alternatives Considered

Alternative B: PlayerInput component with Send Messages / Unity Events

Alternative C: Direct device polling in each consumer

Alternative D: Exclusive Build map

Alternative E: Adapter-owned asset instance, no project-wide asset

Consequences

Positive

Negative

Risks

GDD Requirements Addressed

GDD System Requirement How This ADR Addresses It
player-movementRule 3: decoupled aim, persists on stick release, one vector, mouse/stick parity ACAimResolver.Resolve (else prev); aim-persistence schema-negative
player-movementAC: input → full speed same frame; release → stop next frameadapter at −1000; consumers in Update (binding)
combatRules 1/3/4: three verbs; dash cancels windup; i-frames on inputStrike/Dash edges once per frame; edge consumers in Update
combatRule 9 / EC5–6: revive short channel, cancelled by damage or dashRevive HeldFor; cancel rules in ADR-0015's pure state machine; the only hold
combatDowned/Dead: no combat verbssuppressed in ADR-0015, never in the adapter (stated)
harvestingRule 1: reuses Strike; no hold-to-channelone Strike; no Hold interaction on the asset (test)
base-buildingRule 5 + transition table: client-local; ghost follows cursor (KB/M) / aim stick (gamepad); any of attack/interact/inventory/menu exits; move + camera live; Rule 6 no menu confirmationBuildModeRouting.Step; PointerWorld + AimIsPointer (never Scheme) for the ghost; LMB/RMB shared-control exception; Menu exits
base-buildingRule 7 no rotation; Rule 9 demolish instant from Idle/Move; AC-33 no move verb on the Build mapno Rotate; Demolish on the Player map; asset test
ui-hudGamepad nav + focus; no hover-only; no input gated by animation; rebinding surfaceUnity-reference UI map for UI Toolkit; IRebindService; capture semantics
cameraRule 2: shared aim vector, identical on both schemesvia IAimSource pass-through; loop risk stated + tested
inventory-haulingAC: own bag updates within one frame of pickup inputInteract edge same frame
technical preferencesInput System only; KB/M primary, gamepad partial; no hover-criticalthe asset; both-schemes test; no legacy Input (reflection)

Deferred, with owners

Item Owner Trigger
AwaitableCompletionSource wrap of interactive rebinding (Verification 2)ADR-0014 / first Settings-screen storyblocks the rebinding UI story, not this ADR
Wrapper compiles under 6.3 (Verification 1)Migration step 1the step's exit criterion
Override keying on renamed actions (Verification 3)Migration step 4decides §4's recovery wording
Press threshold from data (Verification 4)Migration step 3Strike RT story
Edge queue for a physics-step consumernew ADRonly if a consumer ever needs it

Performance Implications

Migration Plan

No production code. (1) Driftlands.inputactions assigned project-wide + generated wrapper; EditMode asset tests (both schemes bound; protected actions; no hold interactions; no Rotate; Build map has no move verb; UI map matches Unity's reference set; one enabled Player map); (2) InputFrame/ButtonState/IPlayerInput/AimResolver/BuildModeRouting in Rules.Input with UNIT tests; InputConfig in Data; (3) InputAdapter + input.json + stage-9 rows + the aim-persistence schema-negative (ADR-0006 amendment applied in this change); (4) bindings.json via ADR-0004's atomic writer; (5) IRebindService (Awaitable) for ADR-0014; (6) architecture §2/§4 amendments (IPlayerInput.Current; IAimSource retained as pass-through); (7) registry sync in the same change.

Validation Criteria

Amendments

Related Decisions