Skip to content

ADR-0007 — Character-pawn binding

Status: ✅ Accepted Date: 2026-07-29 Supersedes:Superseded by:


Context

Milestone 10's admin panel named a gap it deliberately did not fake: teleport, noclip, and spectate all need "a Character↔pawn Transform binding that doesn't exist anywhere in this codebase" (Code/Admin/README.md "Not responsible for"). DoorComponent.RequestPurchaseDoor's exploit checklist marks its Distance line N/A for the same reason (Code/World/DoorComponent.cs), and Code/World/README.md names "a real distance check, once a pawn/world-position concept exists for a Character" as future work. This ADR is that concept.

There is no legacy behaviour to preserve here. A full search of Documentation/Legacy/ for teleport, noclip, spectate, an admin overview map, and a server ban/mute mechanism turned up none of them — the closest hits are the jail teleport on arrest (a Crime mechanism, not an admin tool, Legacy/06_CRIME_AND_POLICE.md) and a bare mention of Sandbox-base noclip as a stamina-exempt state (Legacy/08_NEEDS_AND_STATE.md). Neither describes admin tooling. This is genuinely new design, which is exactly when this project's own process calls for an ADR rather than a legacy citation (Documentation/ADR/README.md).

There is also no physical player presence anywhere in this codebase yet. No .scene, no .prefab, Applejack.sbproj's StartupScene/MapStartupScene are empty strings. Character's "spawn" (Code/Characters/Character.cs, 08_DATA_MODEL.md §1) has only ever meant "loaded, and every dependent module's per-character setup is done" — a data-readiness flag — never an instantiated GameObject. This decision has to cover both: what a pawn is, and how a Character finds one.

The engine offers stock movement. S&box ships its own player-movement/character-controller component (Networking & Multiplayer, same family as the GameObject/[Sync]/Rpc primitives 07_NETWORKING.md §1 already relies on) — reinventing movement and its replication is a different, much larger project this milestone does not need to take on.

The testing constraint already in force. Every domain type this codebase compiles under OfflineTests/ has zero engine dependency by design (Standards/00_CODING_STANDARDS.md §8 — "rules and arithmetic live in engine-agnostic types"). Character is one of those types today (08_DATA_MODEL.md §9). A Vector3/Transform field on Character itself would be the first engine-typed field on a type this project has deliberately kept clean since Milestone 4.

If we decide nothing, Milestone 10's three named gaps (teleport/noclip/spectate, real ban records, mute enforcement) and DoorComponent's N/A'd Distance check stay permanently unbuilt.

Decision

A pawn's position lives on a new PawnComponent (Applejack.Movement, a new module) attached to the physical player GameObject — never on Character itself. The host tracks every live pawn in a host-only, in-memory IPawnLocator, keyed by CharacterId. Movement and its replication are not reinvented — whatever S&box's stock controller already networks on that GameObject's Transform is read, not replaced; this binding only supplies the missing link from CharacterId to that GameObject.

Concretely:

namespace Applejack.Movement;

/// <summary>
/// Binds a Character to the physical GameObject the engine's own movement controller drives.
/// Attached once, alongside that controller, on the pawn a Character's connection spawns.
/// </summary>
public sealed class PawnComponent : Component
{
    [Property] public Guid CharacterId { get; set; }

    /// <summary>Host-authoritative movement mode. See ADR-0005 rule 1 -- a client that could
    /// set its own noclip could walk through anything.</summary>
    [Sync(SyncFlags.FromHost)] public bool IsNoclipping { get; private set; }

    protected override void OnAwake() =>
        Scene.GetSystem<ApplejackBootstrap>().Services!
            .GetRequiredService<IPawnLocator>().Register(CharacterId, this);

    protected override void OnDestroy() =>
        Scene.GetSystem<ApplejackBootstrap>().Services?
            .GetRequiredService<IPawnLocator>().Unregister(CharacterId);

    [Rpc.Host]
    public void RequestSetNoclip(bool enabled)
    {
        var character = Rpc.Caller.GetCharacter();
        if (character is null || !character.IsInitialised) return;
        if (character.Id.Value != CharacterId) return; // only your own pawn
        if (!character.HasPermission("admin.noclip")) return;

        IsNoclipping = enabled; // the actual collision/movement-mode toggle is engine-facing,
                                 // reviewed by reading -- see Notes.
    }
}

/// <summary>Host-only, in-memory lookup from a Character to its live pawn. Not persisted --
/// position is ephemeral, exactly like the original had no notion of saving it either.</summary>
public interface IPawnLocator
{
    void Register(Guid characterId, PawnComponent pawn);
    void Unregister(Guid characterId);
    PawnComponent? Find(Guid characterId);
    Vector3? PositionOf(Guid characterId);
}

MovementModule : GameModule depends only on CoreModule and CharacterModule, registers IPawnLocator, and owns a minimal reference player prefab (a capsule plus S&box's stock movement component) and one spawn point, so the binding has something concrete to attach to. Character itself gains no new field, no new dependency, and no change to its OfflineTests compilability.

Distance becomes real. DoorComponent.RequestPurchaseDoor/RequestLockDoor compute range directly against IPawnLocator.PositionOf(actor.Id) and the door's own Transform.Position, inside the RPC handler — engine-facing code, reviewed by reading, not lifted into an engine-agnostic rule type. This mirrors an exception this codebase already accepts elsewhere (AdminKickModule's disconnect step is real but untested, per Code/Admin/README.md "Not unit-tested") rather than inventing a new abstraction to keep a pure-geometry check "testable" for its own sake.

Alternatives considered

Position as a field on Character

The obvious first instinct — one type, one place to look. Rejected: Character is the one domain type every module depends on and the one this project has kept genuinely engine-agnostic since Milestone 4 (08_DATA_MODEL.md §9). A Vector3 field would make every consumer of Character — including plain unit tests that construct one — carry an engine dependency it never needed before, for a value that changes every frame and has no business being persisted or compared for equality alongside Name or Permissions.

[Sync] position on the existing CharacterNetworkComponent

Reuses an existing networked surface instead of adding a new one. Rejected: that component is account/identity replication (CharacterId, CharacterName, IsInitialised) — conflating it with the physical body's Transform mixes two concerns that change for different reasons and at different rates, and a new Movement module keeps Applejack.Characters itself free of any pawn/prefab concept, the same boundary discipline ADR-0002 already asks of every module.

Reimplement movement and replication ourselves

Full control over prediction, noclip, and anti-cheat. Rejected outright as a different, much larger project than "bind a Character to a position" — S&box's own controller already solves movement and its replication; this milestone needs the missing link, not a replacement.

Consequences

Positive

  • Closes three gaps Milestone 10 named rather than faked: teleport/noclip/spectate become buildable, DoorComponent's Distance checks become real, and Character stays testable under OfflineTests with zero engine dependency.
  • IPawnLocator is a small, singular addition — any future module needing "where is this character right now" (a proximity chat range, a vehicle entry check) has one place to ask, rather than each inventing its own scene search.

Negative

  • This milestone includes building the codebase's first-ever player prefab and spawn point — genuinely new scope beyond "a binding," and, like everything since Milestone 2, unverifiable in this environment: no S&box editor has opened this project, so the prefab's actual behaviour is reviewed by reading, not run.
  • IPawnLocator is in-memory only; a pawn that never calls OnDestroy cleanly (a crash, not a graceful disconnect) could leak a stale entry. Not solved here — flagged as a real risk to revisit once a live server exists to observe it under.
  • Noclip's actual collision/movement-mode toggle depends on an S&box API this codebase has not independently confirmed (see Notes) — same category of risk as every other engine-facing assumption since Milestone 2.

Neutral

  • Applejack.Movement is a new module in ApplejackBootstrap.Modules — one more line in the fixed compile-time list, no discovery mechanism added (02_MODULE_MODEL.md §4).
  • Position joins the small set of state this codebase deliberately does not persist (alongside Ooc/Advert cooldowns, Code/Chat/ChatRouter.cs's header comment) rather than the larger set that does.

Compliance

  • Every PawnComponent RPC (RequestSetNoclip and the admin-movement RPCs built on top of IPawnLocator in 22_PLAYER_PRESENCE_AND_ADMIN_TOOLS.md) is walked against the exploit checklist (Standards/03_TESTING_STANDARDS.md) before merge, same as every [Rpc.Host] method in this codebase.
  • Character gaining no engine-typed field is enforced by review, the same way module-boundary discipline is enforced by review and internal today (ADR-0002 Compliance).
  • IPawnLocator.PositionOf being the only path a distance check may use (never a raw scene query) is a review checklist item for any future [Rpc.Host] method with a real-world distance constraint.

Notes

Unverified engine assumptions this decision depends on, named rather than faked, in the same spirit as Documentation/ROADMAP.md's Verification debt section: - Whether a NetworkSpawn()'d GameObject's Transform replicates automatically with no custom [Sync] property needed, or whether the pawn's position needs its own explicit [Sync(SyncFlags.Interpolate)] mirror. - S&box's stock movement/character-controller component's exact noclip-equivalent API (a mode enum, a collision-layer toggle, or something else). - How a camera is reparented or redirected for spectate, built on this same IPawnLocator.

Revisit this when a real S&box editor opens this project for the first time and any of the above turns out wrong — per the Roadmap, that event takes precedence over further feature work regardless. Also revisit if a second consumer of "where is this character" ever needs history (a path, not just a current position) — IPawnLocator returning only the latest position is a deliberate, minimal choice for this pass, not a ceiling.

Addendum (2026-07-31) — closing the CharacterId rebind gap

Context. This ADR's original sketch registers PawnComponent with IPawnLocator once, in OnAwake, keyed on whatever CharacterId the [Property] was set to at spawn. That was written before CharacterNetworkComponent.RequestCreateCharacter/RequestSelectCharacter existed (Milestone 13, Code/Characters/CharacterNetworkComponent.cs) — PlayerPawn.prefab's spawned copy has no character-creation-on-connect flow (PlayerSpawnerComponent.cs's own header comment names this explicitly), so CharacterId stays at the prefab's default (Guid.Empty) for the pawn's entire lifetime today. Every distance-gated [Rpc.Host] method that resolves the caller's pawn via IPawnLocator.Find/PositionOf (DoorComponent.RequestPurchaseDoor and its siblings) therefore looks up a characterId that was never the key any PawnComponent actually registered under, and silently no-ops — named in DoorComponent's own exploit checklist ("no-ops ... if the caller has no registered pawn yet") but not, until now, connected back to why that's always true.

Decision. PawnComponent gains a per-frame check, alongside its existing sibling-lookup pattern (InteractionComponent/CharacterEconomyComponent/etc. already poll GameObject.Components.Get<CharacterNetworkComponent>() every OnUpdate): when the sibling CharacterNetworkComponent.CharacterId differs from this component's own CharacterId and is not Guid.Empty, PawnComponent unregisters its old key, adopts the new CharacterId, and re-registers under it. This is additive to the original OnAwake/OnDestroy registration, not a replacement — a pawn that's never selected a character still registers under Guid.Empty at OnAwake exactly as before (harmless: nothing will ever look it up under that key, since Rpc.Caller.GetCharacter() and CharacterNetworkComponent.CharacterId never resolve to Guid.Empty for an initialised character).

Why not do this from CharacterNetworkComponent instead. CharacterNetworkComponent already depends on Applejack.Movement transitively (ApplejackBootstrap's Modules order already places MovementModule before CharacterModule's consumers), but the reverse — Movement reading its own sibling — keeps the dependency direction this codebase already uses everywhere else (InteractionComponent, CharacterEconomyComponent, ... all read their sibling CharacterNetworkComponent, never the other way around). PawnComponent polling its sibling is consistent with that existing convention rather than a new one.

Consequence. IPawnLocator.Register/Unregister were already idempotent-safe for this reuse (a plain Dictionary<Guid, PawnComponent> swap, confirmed by reading Code/Movement/IPawnLocator.cs); no interface change was needed.