ADR-0012 — Vehicle entry and driving¶
Status: Accepted Date: 2026-07-31 Supersedes: — Superseded by: —
Context¶
ADR-0009 deliberately scoped itself to ownership,
purchase, and the vehicle-key-as-item design, and named exactly what it was leaving out:
"entering/exiting/driving a vehicle is a pawn-to-pawn (or pawn-to-vehicle-entity) interaction
with no precedent in this codebase at all... leaving driving itself to a follow-on decision once
Applejack.Movement has landed and been exercised by at least one real consumer (the admin
teleport/spectate commands it was built for)."
That condition is now met. Applejack.Movement (PawnComponent/IPawnLocator) landed in
Milestone 11 and has a real consumer — the admin /goto//bring//tp//noclip//spectate
commands (22_PLAYER_PRESENCE_AND_ADMIN_TOOLS.md).
This ADR is that follow-on.
What already exists to build on. VehicleComponent.VehicleId, IsOwned/OwnerName, and
AccessLevel.{Use, Passenger, Drive} — a hierarchical grant where Drive satisfies any lower
requested level (ADR-0009's Decision). IOwnershipService.HasAccess already takes an
AccessLevel parameter. PawnComponent is the only thing in this codebase that has ever bound
a player to a world Transform.
What legacy did. Nothing directly comparable — Legacy/13_DEPENDENCY_GRAPH.md confirms the
vehicles plugin used the same generic _Owner table as doors, with no dedicated driving
design of its own. There is no legacy behaviour to preserve here; this is genuinely new,
same as everything else in ROADMAP.md's Milestones 12–18.
Decision¶
Entry goes through the existing
Interactionframework, not a bare RPC.VehicleComponentgains anenter_vehicleverb — a[Rpc.Host] RequestEnterVehicle(bool asDriver)that builds anInteractionRequestand hands it to the caller's ownInteractionComponent.Runner, gated byIOwnershipService.HasAccess(ownership, characterId, asDriver ? AccessLevel.Drive : AccessLevel.Passenger)(aDrivegrant already satisfies aPassengercheck —AccessLevel's existing hierarchy, ADR-0009) and a real distance check (VehicleComponent.CallerWithinRange, reused verbatim — see point 3). TheInteractionframework is the more consistent architectural choice named in this ADR's first draft, and nothing surfaced to justify the RPC-only shortcut: a channelled "climbing in" delay (VehicleConfiguration.EnterDuration) is cheap, free exploit-resistance (StillInRangecancels the channel if the caller runs off mid-entry), and it gives this codebase's second realIInteractionRunnerconsumer beyondItems' equip/use flow.Occupancy and seat-eligibility resolve in
VehicleStore, not in the engine-facingVehicleComponent, mirroring the splitPurchaseAsyncalready established:IVehicleServicegainsRequestEntryAsync(Character, Vehicle, VehicleSeat),Exit(Character, Vehicle), andOccupancyOf(OwnableId).VehicleSeat(Driver/Passenger) andVehicleOccupancy(a small, plainDriver/PassengerCharacterId?pair withHolder/With/Withouthelpers) are new engine-agnostic types inApplejack.Vehicles, unit-tested the same wayAccessGrant/Vehiclealready are. Occupancy itself is host-only, in-memory state — never persisted, exactly as ephemeral asIPawnLocator's own position lookup (ADR-0007), not a new persistence concern.Driving reparents the pawn's existing
GameObject;PawnComponent's identity never moves. This ADR's own Context section already ruled out inventing a "targetGameObject" onPawnComponent— it has none today and gains none here. Instead,PawnComponentgains two plain methods (called host-side only, the same calling convention asTeleport/SetNoclip— see that file's header comment for why these aren't RPCs of their own):public void EnterVehicle(GameObject vehicle, Vector3 seatOffset) { GameObject.SetParent(vehicle, false); GameObject.LocalPosition = seatOffset; GameObject.LocalRotation = Rotation.Identity; IsInVehicle = true; } public void ExitVehicle(Vector3 worldPosition) { GameObject.SetParent(null, false); GameObject.WorldPosition = worldPosition; IsInVehicle = false; }
IsInVehicleis a new[Sync(SyncFlags.FromHost)] bool, added followingIsNoclipping's exact existing pattern (a host-authoritative flag plus a plain setter) rather than a second mechanism. The pawn stays registered withIPawnLocatorunder the samePawnComponentinstance the whole time it is parented — admin/goto//spectate//tpkeep working on a driving player undisturbed, since none of them go through anything this ADR touches.This question is no longer the ADR's riskiest unverified item — see Notes: the real engine source confirms
GameObject.SetParentexists, replicates automatically to observers, and thatnullcorrectly re-parents to the scene root for exit.Distance is required, no exception.
VehicleComponent.CallerWithinRange(already built for the purchase flow) is reused verbatim, both as the gateRequestEnterVehiclechecks before starting the interaction and as theStillInRangecondition that cancels the channel if the caller moves away mid-entry. Exit carries no separate distance check — occupancy itself already proves the caller is physically at the vehicle (they are parented to it), and occupancy is host-only state a client cannot forge.Exit drops the occupant at the vehicle's current world position, offset to a fixed local exit point via plain transform math (
GameObject.WorldTransform.PointToWorld(ExitOffset), confirmed to exist against the real engine — see Notes), never a teleport back to wherever the occupant entered from.DriverSeatOffset,PassengerSeatOffset, andExitOffsetare all per-vehicle[Property]fields onVehicleComponent(different vehicle models need different seat positions), notVehicleConfigurationserver-wide settings.Scope for this pass: exactly one driver seat and one passenger seat per vehicle, not a general seat-count/seat-index system. A vehicle that should hold more than two occupants is explicitly deferred — see Notes and
Code/Vehicles/README.md's "Future improvements." Nothing here callsIOwnershipService.GrantAccessAsync— access is checked, never granted, keeping this ADR strictly additive on top of ADR-0009's purchase flow (owners always satisfy everyAccessLevelalready, so an owner can always enter/drive their own vehicle with no explicit grant needed).
Alternatives considered¶
Ship "enter and drive" as one atomic RPC, no separate exit flow¶
Simpler surface area. Rejected as the sole design: a vehicle with passengers needs an
independent exit for each occupant, and conflating enter/exit into one call complicates the
gate logic (Drive vs Passenger) for no real benefit.
Wait for Milestone 18's NPC framework so both "vehicle passenger" and "NPC as vehicle¶
occupant" are designed together
Would avoid a second design pass if NPCs ever need to drive. Rejected for sequencing: Milestone 18 is deliberately last and gated on unverified pathing/navmesh work; there is no reason player driving should wait on that when ADR-0009's own unblock condition is already satisfied.
Consequences¶
Positive¶
- Closes the last named gap in ADR-0009's scope, using patterns (
Interaction,HasAccess,IPawnLocatordistance checks) already reviewed and proven elsewhere in this codebase. - Gives
PawnComponentits first non-admin, non-teleport real consumer — the exact trigger ADR-0009 named for revisiting this decision.
Negative¶
- Be honest: reparenting a pawn's
GameObjectunder a vehicle and back is confirmed to exist and to replicate (see Notes), but no real S&box editor session has exercised it end to end with a second connected client actually watching — the API surface is confirmed against source, the runtime behaviour still isn't, the same caveat Plugin System Phase 3 already names for its own unverified engine assumptions. - Adds a second
[Rpc.Host]-reachable surface (RequestEnterVehicle/RequestExitVehicle) and a newInteractionverb that must be walked against the exploit checklist, same as every RPC in this codebase.
Neutral¶
- No change to
Ownership,AccessLevel, or the purchase flow — this ADR is additive on top of ADR-0009, not a revision of it.
Compliance¶
- Every new
[Rpc.Host]method orInteractionverb this adds is walked against the exploit checklist (Standards/03_TESTING_STANDARDS.md). - Distance checks reviewed against
DoorComponent's Milestone 11 precedent specifically — no new access RPC ships without one.
Notes¶
Resolved against the real engine source, not by editor session. Per this codebase's own
convention of checking ~/Projects/sbox-public before marking something unverified (see
ModuleRunner.Shutdown()'s own July 2026 precedent, Documentation/ROADMAP.md's Verification
debt section), both risks this ADR originally named as needing an editor spike were instead
confirmed directly against engine source during implementation:
- GameObject reparenting and its replication.
GameObject.SetParent(GameObject value, bool keepWorldPosition = true)exists (engine/Sandbox.Engine/Scene/GameObject/GameObject.cs:487-512), acceptsnullto reparent back to the scene root (value ??= Scene, same file), and — for aNetworkSpawn()'dGameObject— broadcasts the change to every observing client automatically via an[Rpc.Broadcast] Msg_SetParentcall (GameObject.cs:511, handled bySetParentFromNetworkatGameObject.cs:517-536, the RPC itself inGameObject.Network.cs:314-340). No custom[Sync]mirror is needed for a second connected client to see the reparent.GameObject.LocalPosition/LocalRotation(for placing the pawn at its seat offset once parented) andTransform.PointToWorld(for exit's world-position math) are both real, confirmed APIs too (GameObject.LocalTransform.cs:10-30;PointToWorldused throughoutengine/Sandbox.Engine/, e.g.Editor/Gizmos/Hitbox/Hitbox.cs:193). What remains genuinely unverified is runtime behaviour under real multiplayer conditions (interpolation smoothness across the reparent, whether a client-side prediction/camera system needs its own awareness of the switch) — the API existing and broadcasting is no longer in question. - Whether a single suspend-movement bool is sufficient. Not fully, and this is worth being
precise about now rather than discovering it later: the real stock movement controller,
PlayerController, exposesUseInputControls(PlayerController.Input.cs:6), and itsOnFixedUpdate/DefaultControlspath genuinely gatesInputMove()/InputJump()/ducking behind it (PlayerController.DefaultControls.cs:75) — so a bool does stop new input from being applied, the same shapeIsNoclippingalready uses. ButPlayerController.PrePhysicsStep()(PlayerController.cs:185-194) still runs gravity/velocity/ground-friction physics regardless of that flag (onlyIsProxyskips it) — so residual velocity from before the player entered the vehicle is not zeroed byIsInVehiclealone. Fully freezing the pawn, if that turns out to matter once this is exercised in a real session, needs eitherComponent.Enabled = falseon thePlayerControlleritself or an explicit velocity reset —IsInVehicleis deliberately shipped anyway (it is real, useful signal for animation/UI state and for a futurePlayerControllerintegration to read), with this limitation named rather than silently assumed away. SeeDocumentation/ROADMAP.md's Verification debt section, where both findings are recorded per this ADR's own Compliance section.
What this pass deliberately does not build: a general seat-count/seat-index system (exactly
one driver seat, one passenger seat), releasing a seat automatically when its occupant's pawn is
destroyed by a hard disconnect rather than a graceful exit (the same class of cleanup gap
IPawnLocator's own "Future improvements" already names), and calling
IOwnershipService.GrantAccessAsync from anywhere in this flow (access is read, never granted —
ADR-0009's Notes still name that as the next module to build, once a real caller wants to loan a
car; this ADR is not that caller). Each is named so it is not silently forgotten, not because it
is hard.