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 playerGameObject— never onCharacteritself. The host tracks every live pawn in a host-only, in-memoryIPawnLocator, keyed byCharacterId. Movement and its replication are not reinvented — whatever S&box's stock controller already networks on thatGameObject'sTransformis read, not replaced; this binding only supplies the missing link fromCharacterIdto thatGameObject.
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, andCharacterstays testable underOfflineTestswith zero engine dependency. IPawnLocatoris 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.
IPawnLocatoris in-memory only; a pawn that never callsOnDestroycleanly (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.Movementis a new module inApplejackBootstrap.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/Advertcooldowns, Code/Chat/ChatRouter.cs's header comment) rather than the larger set that does.
Compliance¶
- Every
PawnComponentRPC (RequestSetNoclipand the admin-movement RPCs built on top ofIPawnLocatorin 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. Charactergaining no engine-typed field is enforced by review, the same way module-boundary discipline is enforced by review andinternaltoday (ADR-0002Compliance).IPawnLocator.PositionOfbeing 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.