Skip to content

Generated reference. This page mirrors Code/Vehicles/README.md, this module's source-of-truth documentation (Standards/02_DOCUMENTATION_STANDARDS.md §1). Edit it there, not here -- this file is regenerated on every docs build and any local edit is silently overwritten.

Vehicles

Purpose. The second ownable world entity, after World's Door. Sells vehicles through the same purchase pattern PropertyStore.PurchaseAsync already established for doors -- Ownerships.md names this module explicitly as the eventual second consumer of the generic Ownership claim. See ADR-0009 for the full reasoning (why a key item rather than a direct spawn, why Ownership rather than a dedicated VehicleOwnership type) and 08_DATA_MODEL.md §10 for the domain type's shape.

Responsibilities. - VehicleOwnableId Id, DisplayName. Deliberately as thin as Door; see 08_DATA_MODEL.md §10 for why its id is its Ownership.EntityId, not a separate id space. - VehicleConfiguration — the operator-tunable purchase price (PurchasePrice, default 2000 -- no legacy precedent; the original manufactured a vehicle item through the Arms Dealer job rather than selling at a fixed dealership price, see Legacy/13_DEPENDENCY_GRAPH.md §2) and interaction range (InteractionRange, default 150, same unverified-unit-scale caveat as DoorConfiguration.InteractionRange), following 05_CONFIGURATION.md's per-module GameResource pattern. - IVehicleService / VehicleStore — registers a placed vehicle's persisted record (RegisterAsync, load-or-create, called from VehicleComponent.OnAwake), and PurchaseAsync: the same validate → assign-ownership → debit-money → publish-event sequence, in the same order and for the same reason, as PropertyStore.PurchaseAsync. See that method's own doc comment; not repeated here since it is the same decision, not a new one. Key minting is no longer part of this sequence — PurchaseAsync publishes VehiclePurchased and returns; it does not know VehicleKeyMinter exists. - VehicleKeyMinter — subscribes HandleAsync to VehiclePurchased from VehicleModule.RegisterServices (Documentation/Architecture/04_EVENT_BUS.md §7), minting one ItemInstance into the buyer's inventory once ownership is assigned (ADR-0009). The first real, first-party IEventBus subscriber in the codebase — proof that delivery works through the actual shared, DI-resolved bus, not just a test's direct construction. Takes only the collaborators minting needs (IItemRegistry, IInventoryService, InventoryConfiguration, VehicleConfiguration, IGameLog), so it is constructible and testable with no VehicleStore, no event bus, and no module-boot machinery at all — see UnitTests/Vehicles/VehicleKeyMinterTests.cs. - VehicleKeyBehaviour / VehicleKeyState — the ItemBehaviour/per-instance state pair VehicleKeyMinter.HandleAsync uses to mint one ItemInstance into the buyer's inventory once ownership is assigned (ADR-0009). Deliberately declared here, not in Code/Items/, so Items never gains a dependency on Ownerships/Vehicles just to host a behaviour only one consumer uses — see that file's own header comment. Assets/Items/Vehicles/vehicle_key.item is the one authored ItemDefinition today, referenced by VehicleConfiguration.VehicleKeyItemId. A missing/misconfigured id, a full inventory, or a failed inventory save are all logged as operator-visible anomalies, never a reason to fail or roll back the purchase — the vehicle is already genuinely the buyer's by the time minting runs, same reasoning as PurchaseAsync's own debit-failure anomaly. - VehicleComponent — the placed-in-map entity: [Property] VehicleId (generated once at spawn, following DoorComponent.DoorId's precedent), [Sync(FromHost)] IsOwned / OwnerName, and [Rpc.Host] RequestPurchaseVehicle() -- carries the same real distance check as DoorComponent (CallerWithinRange, VehicleConfiguration.InteractionRange against IPawnLocator.PositionOf). Since Milestone 12 (ADR-0012) also owns entering, exiting, and driving -- see the dedicated subsection below. - VehicleModule — registers IVehicleService and the VehicleDocument schema, and subscribes VehicleKeyMinter.HandleAsync to VehiclePurchased. Depends on InteractionModule since Milestone 12, for the same "reaching into a sibling component" reason InventoryModule.cs already documents its own identical edge for.

Entering, exiting, and driving (Milestone 12)

See ADR-0012 for the full decision and its Notes for two engine-API risks confirmed against ~/Projects/sbox-public during implementation (GameObject.SetParent's existence/replication; the real limits of a single suspend-movement bool).

  • Entry is an Interaction verb, not a bare RPCVehicleComponent.RequestEnterVehicle(bool asDriver) builds an enter_vehicle InteractionRequest (VehicleConfiguration.EnterDuration, default 1.5s) and starts it on the caller's own InteractionComponent.Runner, found via IPawnLocator.FindGameObject.Components.Get<InteractionComponent>(). A StillInRange condition cancels the channel if the caller moves away mid-entry; OnComplete is where the seat is actually granted and the pawn physically moved (below) — completing the interaction is not itself the authorization, VehicleStore.RequestEntryAsync's access check is.
  • Occupancy and access-eligibility resolve in IVehicleService/VehicleStore, not in the engine-facing component — the same split PurchaseAsync already established. RequestEntryAsync(Character, Vehicle, VehicleSeat) checks IOwnershipService.HasAccess (AccessLevel.Drive for the driver seat, AccessLevel.Passenger — which Drive also satisfies — for the passenger seat), refuses an already-held seat or a caller already seated in a different vehicle, and is idempotent for a caller re-requesting a seat they already hold. Exit(Character, Vehicle) releases whichever seat they hold, a safe no-op otherwise. OccupancyOf(OwnableId) reads the current state. All three are unit-tested directly — UnitTests/Vehicles/VehicleStoreTests.cs.
  • VehicleSeat (Driver/Passenger) and VehicleOccupancy (a small plain CharacterId?/CharacterId? pair with Holder/With/Without helpers) are new, engine-agnostic types — Code/Vehicles/VehicleSeat.cs, unit-tested on their own in UnitTests/Vehicles/VehicleOccupancyTests.cs. Occupancy is host-only, in-memory state inside VehicleStore, never persisted — exactly as ephemeral as IPawnLocator's own position lookup, not a property of the saved Vehicle/Ownership records.
  • Driving reparents the pawn's existing GameObject; PawnComponent's identity never changes. VehicleComponent's entry OnComplete calls the new PawnComponent.EnterVehicle(GameObject vehicle, Vector3 seatOffset) (SetParent + local seat offset + IsInVehicle = true) and RequestExitVehicle calls the new PawnComponent.ExitVehicle(Vector3 worldPosition) (SetParent(null, ...) + world position + IsInVehicle = false) — both live in Code/Movement/PawnComponent.cs, not here, since PawnComponent owns its own GameObject mutation surface (the same reasoning Teleport/SetNoclip already established). The pawn stays registered with IPawnLocator under the same instance throughout — admin /goto//spectate//tp are undisturbed.
  • DriverSeatOffset, PassengerSeatOffset, ExitOffset are per-vehicle [Property] fields on VehicleComponent, author-tuned per prefab (different vehicle models need different seat positions) — not VehicleConfiguration server-wide settings. Exit resolves ExitOffset against the vehicle's current world transform at the moment of exit (GameObject.WorldTransform.PointToWorld(ExitOffset)), never the position the occupant entered from.
  • DriverCharacterId/PassengerCharacterId ([Sync(FromHost)] Guid, empty when the seat is empty) are VehicleComponent's own replicated snapshot of IVehicleService.OccupancyOf for this specific vehicle — same relationship IsOwned/OwnerName already have to the persisted Ownership record, just for in-memory occupancy instead.
  • Scope for this pass: exactly one driver seat and one passenger seat per vehicle. A disconnect/pawn-destroy while seated does not release the vehicle seat automatically (the seat is only released by RequestEntryAsync's own disconnect-during-channel guard, or by an explicit RequestExitVehicle call) — see "Future improvements."

Not responsible for. - Who owns a vehicle — Ownership's job; VehicleStore calls IOwnershipService, it does not duplicate its logic. - Moving moneyEconomy's job; VehicleStore calls IEconomyService.CanAfford/ DebitAsync, it never touches a balance directly. - Granting access. AccessLevel.Drive/.Passenger are checked by the entry flow above, never granted by anything in this module — IOwnershipService.GrantAccessAsync still has no caller anywhere in Applejack.Vehicles. An owner always satisfies every AccessLevel already, so this is only a gap for loaning a car to a second character. See "Future improvements." - Fuel/upkeep, an ownership cap, impound, or dealership UI — each its own smaller, independent follow-on decision per ADR-0009's Notes.

Dependencies. Core (IServiceRegistry, IServiceResolver, IPersistenceBackend, ISchemaRegistry, IEventBus, IGameLog), Character (Character, ConnectionExtensions.GetCharacter()), Economy (IEconomyService), Ownership (IOwnershipService, OwnableId, OwnerRef, AccessLevel), Movement (IPawnLocator, for the distance check; PawnComponent.EnterVehicle/ExitVehicle for driving), Items (IItemRegistry, ItemDefinition, ItemInstance, ItemBehaviour -- VehicleKeyBehaviour extends it — used by VehicleKeyMinter, not VehicleStore), Inventories (IInventoryService, InventoryConfiguration, CharacterInventoryHolder — the same collaborators InventoryModule.GiveAsync itself uses, also VehicleKeyMinter's, not VehicleStore's), Interaction (InteractionRequest, InteractionComponent, StillInRange — for RequestEnterVehicle, Milestone 12), the S&box engine (Component, GameResource, Rpc, Sync).

Public API. - IVehicleServiceRegisterAsync(OwnableId, string displayName), Find(OwnableId), PurchaseAsync(Character buyer, Vehicle vehicle), RequestEntryAsync(Character, Vehicle, VehicleSeat), Exit(Character, Vehicle), OccupancyOf(OwnableId). - VehicleComponentRequestPurchaseVehicle(), RequestEnterVehicle(bool asDriver), RequestExitVehicle(), DriverSeatOffset/PassengerSeatOffset/ExitOffset, DriverCharacterId/PassengerCharacterId. - VehicleSeat, VehicleOccupancy. - VehicleKeyMinter (internal) — HandleAsync(VehiclePurchased), subscribed by VehicleModule.RegisterServices. Constructible directly for tests; no event bus required. - VehicleKeyBehaviour, VehicleKeyState. VehicleKeyStateSerializer (internal) is registered with IItemStateRegistry from VehicleModule.RegisterServices — without it the minted key's VehicleKeyState is dropped on the next inventory save and the key stops identifying its vehicle across a relog. See 09_ITEM_FRAMEWORK.md §2.1.

Events published. - VehiclePurchasing — cancellable, published by VehicleStore once eligibility (unowned, affordable) is confirmed but before ownership is assigned or money moves. Mirrors Applejack.World.DoorPurchasing exactly. - VehiclePurchased(Vehicle, Character) — published once ownership has been assigned. Mirrors Applejack.World.DoorPurchased. VehicleKeyMinter.HandleAsync is the one subscriber today (Documentation/Architecture/04_EVENT_BUS.md §7) — the first real, first-party IEventBus subscription anywhere in the codebase. - Entry/exit publish nothing of their own — IsInVehicle/DriverCharacterId/ PassengerCharacterId are [Sync] state, not events; no other module reacts to a vehicle being entered yet.

Persistence. VehicleDocument { Id, DisplayName } under vehicles/{id}, schema version 1. Ownership is a separate document under ownership/{entityId}, owned by the Ownership module -- this module never writes it directly. The minted key ItemInstance lives in the buyer's own inventories/{characterId} document (Inventories' persistence, unchanged by this module) -- no new persistence mechanism. Occupancy (Milestone 12) is not persisted at all — see the Entering/exiting/driving subsection above.

Future improvements. - Exercising Drive/Passenger grants via IOwnershipService.GrantAccessAsync -- entry now checks AccessLevel, but nothing anywhere calls GrantAccessAsync to loan a car to a second character yet; the owner's implicit full access covers solo use, not lending. - A re-key mechanic for a lost/destroyed key with no matching AccessGrant left standing -- deliberately deferred, see ADR-0009's Notes. - More than one passenger seat, or a general seat-count/seat-index system for vehicles that should hold more than two occupants -- this pass supports exactly one driver and one passenger. See ADR-0012's Notes. - Releasing a seat automatically when its occupant's pawn is destroyed by a hard disconnect, not just a graceful RequestExitVehicle call or a disconnect mid-entry-channel (both handled today) -- the same class of cleanup gap Code/Movement/README.md's own "Future improvements" already names for IPawnLocator. - Fuel/upkeep as a recurring cost, an ownership cap, impound on theft/loss, and dealership UI.