Skip to content

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 Interaction framework, not a bare RPC. VehicleComponent gains an enter_vehicle verb — a [Rpc.Host] RequestEnterVehicle(bool asDriver) that builds an InteractionRequest and hands it to the caller's own InteractionComponent.Runner, gated by IOwnershipService.HasAccess(ownership, characterId, asDriver ? AccessLevel.Drive : AccessLevel.Passenger) (a Drive grant already satisfies a Passenger check — AccessLevel's existing hierarchy, ADR-0009) and a real distance check (VehicleComponent.CallerWithinRange, reused verbatim — see point 3). The Interaction framework 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 (StillInRange cancels the channel if the caller runs off mid-entry), and it gives this codebase's second real IInteractionRunner consumer beyond Items' equip/use flow.

Occupancy and seat-eligibility resolve in VehicleStore, not in the engine-facing VehicleComponent, mirroring the split PurchaseAsync already established: IVehicleService gains RequestEntryAsync(Character, Vehicle, VehicleSeat), Exit(Character, Vehicle), and OccupancyOf(OwnableId). VehicleSeat (Driver/Passenger) and VehicleOccupancy (a small, plain Driver/Passenger CharacterId? pair with Holder/With/Without helpers) are new engine-agnostic types in Applejack.Vehicles, unit-tested the same way AccessGrant/Vehicle already are. Occupancy itself is host-only, in-memory state — never persisted, exactly as ephemeral as IPawnLocator'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 "target GameObject" on PawnComponent — it has none today and gains none here. Instead, PawnComponent gains two plain methods (called host-side only, the same calling convention as Teleport/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;
}

IsInVehicle is a new [Sync(SyncFlags.FromHost)] bool, added following IsNoclipping's exact existing pattern (a host-authoritative flag plus a plain setter) rather than a second mechanism. The pawn stays registered with IPawnLocator under the same PawnComponent instance the whole time it is parented — admin /goto//spectate//tp keep 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.SetParent exists, replicates automatically to observers, and that null correctly 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 gate RequestEnterVehicle checks before starting the interaction and as the StillInRange condition 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, and ExitOffset are all per-vehicle [Property] fields on VehicleComponent (different vehicle models need different seat positions), not VehicleConfiguration server-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 calls IOwnershipService.GrantAccessAsync — access is checked, never granted, keeping this ADR strictly additive on top of ADR-0009's purchase flow (owners always satisfy every AccessLevel already, 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, IPawnLocator distance checks) already reviewed and proven elsewhere in this codebase.
  • Gives PawnComponent its first non-admin, non-teleport real consumer — the exact trigger ADR-0009 named for revisiting this decision.

Negative

  • Be honest: reparenting a pawn's GameObject under 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 new Interaction verb 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 or Interaction verb 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), accepts null to reparent back to the scene root (value ??= Scene, same file), and — for a NetworkSpawn()'d GameObject — broadcasts the change to every observing client automatically via an [Rpc.Broadcast] Msg_SetParent call (GameObject.cs:511, handled by SetParentFromNetwork at GameObject.cs:517-536, the RPC itself in GameObject.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) and Transform.PointToWorld (for exit's world-position math) are both real, confirmed APIs too (GameObject.LocalTransform.cs:10-30; PointToWorld used throughout engine/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, exposes UseInputControls (PlayerController.Input.cs:6), and its OnFixedUpdate/DefaultControls path genuinely gates InputMove()/InputJump()/ducking behind it (PlayerController.DefaultControls.cs:75) — so a bool does stop new input from being applied, the same shape IsNoclipping already uses. But PlayerController.PrePhysicsStep() (PlayerController.cs:185-194) still runs gravity/velocity/ground-friction physics regardless of that flag (only IsProxy skips it) — so residual velocity from before the player entered the vehicle is not zeroed by IsInVehicle alone. Fully freezing the pawn, if that turns out to matter once this is exercised in a real session, needs either Component.Enabled = false on the PlayerController itself or an explicit velocity reset — IsInVehicle is deliberately shipped anyway (it is real, useful signal for animation/UI state and for a future PlayerController integration to read), with this limitation named rather than silently assumed away. See Documentation/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.