Adding a vehicle
How DyingStar networking works explains the multiplayer basics in plain language. This is a step-by-step build guide — but good news: most of the time a new vehicle is just a scene to assemble, with little or no new code.
This guide walks through creating a new drivable vehicle (truck, car, rover…). Vehicles are server-authoritative networked props: the game server simulates the physics and replicates the state to every client, exactly like the other props (see Props network management). A vehicle adds two things on top: driving (a powertrain) and seats (driver / passengers).
All the moving parts already exist as reusable pieces — in most cases a new vehicle is just a scene to assemble, not new code.
The pieces
| Script | Role |
|---|---|
scenes/_universe/vehicles/vehicle.gd (class_name Vehicle) | The vehicle base: body/wheels, physics, driving, networking, cargo. Put it on the scene root. |
scenes/_universe/vehicles/vehicle_powertrain.gd | Engine math (electric / thermal gearbox). Owned by Vehicle, configured through its @exports — you don't touch it. |
scenes/_universe/vehicles/vehicle_seat.gd (class_name VehicleSeat) | One seat (driver or passenger), boarded by looking at it. You drop one per seat. |
scenes/_universe/vehicles/vehicle_readout.gd (class_name VehicleReadout) | The driver's debug readout (speed, RPM, load, bays) in the debug panel; the in-cab screen is VehicleDashboard. |
1. Where to put the files
- Scene:
scenes/_universe/vehicles/<category>/<type>/<name>.tscn(e.g.scenes/_universe/vehicles/ground/trucks/truck.tscn). - Assets (3D model, materials, textures): follow the project conventions in Files structure and 3D models. Do not invent a new layout.
2. Build the scene
Create a scene whose root is a VehicleBody3D and attach vehicle.gd to it.
You have two options for the body:
- Real 3D model (GLB) — instance the artist's
.glbas a child of the root and add it to the groupvehicle_model. That switches the procedural blockout off automatically (no flag), andvehicle.gdwires the model's parts by name at runtime: wheels, steering wheel and doors. See Using a real 3D model below, and the modeler-facing Vehicle 3D models page for the naming / pivot / UV rules. - Parametric blockout (placeholder) — with no real model,
vehicle.gdgenerates a box-built body, chassis collision, four wheels, cameras and the cargo bay from its exported dimensions (@tool, so it rebuilds live in the editor). Tune it via the Body / Cab / Bed / Wheels export groups, and carve the cab opening with Cab → Cab Cutout Size / Offset.
The blockout is not just a leftover — it's the "design without a GLB yet" path. A developer can
create a new vehicle (rover, car, bike…) and test driving, physics, seats and cargo right away,
while the 3D artist is still making the model. When the GLB lands, drop it in the vehicle_model
group and the blockout visual switches off — no code change. So keep it: removing it would force
every new vehicle to wait for a finished model.
Either way, collision, cameras and the cargo bay are still built; only the visual body and the
procedural tires are skipped when a real model is present. The generated nodes are transient (no
owner), so the .tscn stays minimal — only the root, its script and your designer-placed nodes (the
seats, the GLB instance, door handles…) are saved.
Scene layout at a glance
The finished truck — the artist's GLB body wrapped by the Godot rig:

Its scene tree — the GLB Model plus the rig nodes you add around it:

Model(groupvehicle_model) — the GLB: body,*_wheels,Steering_wheel,Front_*_doors and the screen meshes. Everything below it is the rig you add in Godot.SeatDriver/SeatPassenger(VehicleSeat) — the seats, boarded by looking at them (§3).Gui3D → SubViewport →dashboard UI — the in-cab screen (§Dashboard).Light— thevehicle_lightlamps (head / brake / cabin).RearCameraRear/RearCameraRetroL/RearCameraRetroR— reversing camera + mirrors.CargoLoadingZone— where dropping loads the bed.HandleFL/HandleFR(VehicleDoorHandle) — the door handles (§Doors).
Note the handles are placed at the root (siblings of Model), each on its door's visible handle.
At runtime the code re-parents each one under its door mesh so it swings with the door.
The VehicleBody3D root goes on the vehicle layer with Mask MASK_SOLID (world + player +
vehicle + prop). The seats and the door handles go on the interactable layer with
Mask 0 (monitoring off — they are passive look-at targets; the scripts set it in _ready).
Seated, your interaction ray passes through the seats of your own vehicle, so it reaches the door
handles around you. Set these in the Inspector; see
Collision layers & masks for the full picture and the truck as a worked
example.
Component bays
Drop a VehicleComponentSlot (a Marker3D) per hatch, aim it where the part should sit, and name
the hatch that guards it in door_id. The vehicle finds its own bays by type — no per-vehicle
wiring, and a new chassis just adds nodes. Full details on
Vehicle components.
3. Add the seats
For each place, add a VehicleSeat node (an Area3D with vehicle_seat.gd) as a child of
the root, then give it two children:
Truck (VehicleBody3D, vehicle.gd)
├── SeatDriver (Area3D, vehicle_seat.gd) role = Driver
│ ├── CollisionShape3D (BoxShape3D) ← the seat itself: the box you LOOK at to board
│ └── SitPoint (Marker3D) ← where the occupant sits (driver: the camera eye)
└── SeatPassenger (Area3D, vehicle_seat.gd) role = Passenger
├── CollisionShape3D (BoxShape3D)
└── SitPoint (Marker3D)
role(inspector):Drivercontrols the vehicle (drive input + HUD);Passengerjust rides along. Set this per seat.- The box (
CollisionShape3D): fit it to the seat itself, cushion and back, inside the cab (around 0.6 × 0.9 × 0.6 m, centred a little under theSitPoint). It is what the player looks at to board: through the open door, within reach of the interaction ray (3 m). SitPoint(Marker3D): where the occupant is seated. For the driver it is also the camera eye, so place it at head height inside the cab, facing forward (the vehicle's local-Z).
You don't wire anything: the Vehicle discovers its seats automatically. A seat is a look-at
target, like a door handle: on the interactable layer, never monitoring. You board the seat you
look at — not a patch of ground you stand on — so two seats side by side are never confused.
Looking at a seat shows [F] Driver seat / [F] Passenger seat; a taken seat shows Driver seat taken, a shut door "Open the door first (aim at the handle)" (its handle box covers the seat
behind it, so it takes the aim first).
On enter_vehicle the server finds the seat the client named and refuses it unless the player's
eye is within VehicleSeat.REACH_M (4 m: the ray's 3 m plus one for latency) of the seat's box and
has a clear line of sight to it (the vehicle's own hull excepted, as for a door handle). The seat
boxes used to stand beside the cab and the server never asked: any client could sit in any truck
from anywhere.
::: The driver gets free mouse look while driving and exits
with Y; the prompt is hidden while seated, and on exit you are dropped beside the seat you used.
A seat refuses a second occupant, and if a seated player disconnects the server frees their seat automatically. The driver/passenger logic lives in the networked path — the only path there is.
Every seat's occupancy is replicated to all clients (a seats map on the vehicle, alongside
doors), so a taken passenger seat shows as occupied too — not only the driver. Without it a
client couldn't tell a passenger seat was in use and would let two players appear to sit in the same
spot.
Seated poses
A seated player plays a matching animation: the driver holds the wheel (Driving), a passenger
sits (Sitting_Nodding). Remotes see it because the seat state is replicated as a seat:<role>:<n>
event (and unseat:<n> on exit) — see Character animation and
the event-field idiom. A tunable puppet offset lines
the sit pose up with the seat's SitPoint; it shifts the visible body only, so the driver's
first-person camera stays where the seat puts it.
4. Configure the chassis (the powertrain comes from the engines)
A vehicle has no engine_power, max_speed_kmh or propulsion_type. How hard it pulls, how
fast it goes and how steep a slope it climbs are derived from the engines bolted into its bays —
see Vehicle components. Fit no engine and the vehicle cannot move and
cannot even be started.
What still belongs to the chassis, in the Chassis physics export group: its transmission
(pump_efficiency, hydraulic_efficiency, torque_factor), its shape (drag_coefficient,
frontal_area_m2), its rolling resistance (rolling_coefficient, rolling_factor), the local
air_density, how many engines it will run (max_engines), what it leaves the works with
(factory_components), and its bare weight (empty_mass — modules and payload excluded).
Steering, brakes, wheels and suspension are unchanged, and so is Cargo (max_payload, overload)
for the load limiter.
You never edit vehicle_powertrain.gd — the Vehicle feeds it from VehicleDriveSpec, which is
rebuilt whenever a part is fitted or removed.
See Inspector reference — every @export at the bottom of
this page for the full list of tunables (Drive, Steering, Body/Cab/Bed, Wheels, Real 3D model,
Electric, Thermal gearbox, Cargo, Debug) with their defaults.
5. Register it on the network
A vehicle only replicates if the network layer knows it:
-
Spawn registry — add the scene path to
props_scenein bothserver/client.gdandserver/server.gd:'scenes/_universe/vehicles/<category>/<type>/<name>.tscn':
preload('res://scenes/_universe/vehicles/<category>/<type>/<name>.tscn'),(These are plain string paths — if you ever move the scene, update them by hand; Godot's UID rename does not touch string paths.)
-
Replication definition — vehicles use the
vehicleprop type, defined inhorizonserver/ds_genericprops/props/vehicle_def.json, split across three channels by how often each property actually changes:Zone Rate Properties 0 30 Hz (LOD: 10 Hz past 100 m) position,rotation,pilot_uuid,steering,speed,velocity_local,wheel_kmh,power,handbrake,headlights,engine,doors,seats,components,limiter_on,limiter_kmh1 1 Hz horn,horn_special,mass,cargo_mass,suspension,odometer_km6 1 Hz scenename,parent_idIf you reuse the
vehicletype, there is nothing to add. A brand-new type needs its own<type>_def.json— see Replication definition files.
The replicated state, as the database holds it
Every key goes out from the first tick, with its value even when it is the default — an admin
reading the database sees "headlights": false, "pilot_uuid": "", every door shut and every seat and
bay empty, not a missing key. Vehicle.full_state() builds it; the server sends only what changed
after that, and the whole state once more ~2 s after spawn (Horizon drops in silence an update for an
object it is still creating). The keys inside seats, components and doors are snake_case:
"seats": {"seat_driver": "", "seat_passenger": ""},
"components": {"slot_fl": "<engine uuid>", "slot_fr": "", "slot_rl": "", "slot_rr": ""},
"doors": {"front_l_door": false, "front_r_door": false, "hatch_fl": false, …}
- Seat and bay keys are their node names (PascalCase, Godot's convention:
SeatDriver,SlotFL) turned into snake_case byVehicleNetKey, used wherever a key is written or read back. An older save ("SeatDriver","Slot_FL") reads back under the new key. doorsholds only the vehicle's own doors (thedoor_ids of its handles): an unknown id sent by a client is refused, and an old key read from the database (a mesh name from before a rename) is dropped.- A part's bay is its own
slot_id(snake_case too), published when it is fitted and taken out. - Node names on the vehicle scenes are checked by
test_vehicle_node_names.gd(PascalCase only).
A property (or a whole type) that is not whitelisted is dropped silently → the vehicle appears on
the server but is invisible to clients (Object definition not found for type: …). Add it to
the def and rebuild Horizon.
6. Test
In game (F5) — spawn the vehicle, open a door, look at the seat behind it, E to board as driver or passenger, drive, Y to leave.
There used to be a vehicle_bench.tscn that drove a vehicle locally, with its own keyboard input,
its own chase camera and its own debug keys. Nobody used it, and it was a second control path
that could disagree with the real one — the kind of divergence you only notice when a bug appears
in game and not on the bench. Everything a driver can do now travels player to server, and F5 is
the only way to try a vehicle.
Using a real 3D model (GLB)
Instance the artist's .glb under the VehicleBody3D root and add it to the group
vehicle_model. The blockout visual turns off and vehicle.gd wires the parts by name at
runtime — every part is optional (one that isn't there is simply skipped). The naming / pivot /
UV rules the artist follows are on the Vehicle 3D models page.
Godot's glTF import treats trailing tokens like _wheel / -wheel (also -col, -rigid…) as
node-type suffixes: a part named Steering_wheel is silently turned into a VehicleWheel3D and
renamed Steering (you'll see "VehicleWheel3D should be a child of a VehicleBody3D" warnings).
Our code builds the physics itself, so this is unwanted. On the .glb → Import tab, untick
Nodes → Use Node Type Suffixes (and Use Name Suffixes) and Reimport, so parts stay
plain MeshInstance3D with their full names.
Wheels (any number)
The game finds the wheel parts in the model — nodes already typed VehicleWheel3D, or plain
meshes whose name contains wheel — reads each position, and creates a real physics wheel as a
direct child of the vehicle there, reparenting the visual mesh onto it (so it rides the suspension,
steers and spins). Front vs rear comes from the name (front/rear), else the axle nearest the cab
steers. Tune in the Wheels + Real 3D model groups: wheel_radius, suspension_rest, and
real_wheel_spin_axis (flip it if wheels spin around the wrong axis).
Steering wheel
A mesh named steering_wheel turns with the steering angle — cosmetic, it reuses the already-
replicated steering (nothing new on the wire). It spins around steering_wheel_axis in the mesh's
own local frame (so a tilted column still turns in-plane); steering_wheel_ratio sets the lock-to-lock
feel. Absent → no-op.
(0, 1, 0), not (0, 0, 1) — Blender Z-up → Godot Y-upThe disc's normal (its spin axis) is +Z in Blender, but the glTF export converts Z-up → Y-up, so
in Godot that normal becomes local +Y. So steering_wheel_axis is typically (0, 1, 0) (use
(0, -1, 0) if it spins the wrong way); (0, 0, 1) makes it wobble around the vertical instead. For an
angled column this only holds if the tilt lives in the wheel's object rotation, not baked into the
geometry — see the modeler page: keep the disc flat in local
space, tilt the object, and don't apply that rotation.
Doors (server-authoritative) + handles
State — open/close is decided by the server and replicated to everyone via the doors
property (a {door_id: open} map), so it must be whitelisted in vehicle_def.json (step 5
above). On a change every client plays the Blender clip <door_id>_open / <door_id>_close; a door
with no clip falls back to a code hinge-swing on the mesh named door_id (about door_hinge_axis).
Handles are placed in Godot (like seats), not in the model. Per door:
- Add a
VehicleDoorHandlenode (anArea3D) under the vehicle, on the door's visible handle. - Give it two small
CollisionShape3Dchildren — one on the door's exterior handle, one on the interior side (the handle you'd reach when seated). Place each box just proud of the body surface (see the sightline note below). - Set its exports:
Door Id— the door's mesh / clip prefix, in snake_case (e.g.front_l_door; the mesh is found whatever its case).Outdoor Shape— the exterior box (used when interacting on foot). Drag the child in.Indoor Shape— the interior box (used when interacting seated). Drag the child in.Open Angle Deg— fallback swing angle (used when the door has no Blender clip).Reverse— swing the other way (fallback) / play the clip backwards (anim). Two doors that should open outward need oppositeReverse.
The handle sits on the interact layer, so the player looks at it and presses F (action) to
toggle the door — no proximity zone, no per-vehicle wiring. Prompt: [F] Open door / [F] Close door.
Operating a handle on foot also plays a short reach-out gesture on your character, replicated to everyone (a non-wheel emote — see Character animation). A driver or passenger toggling a door from inside does not.
The interaction is server-authoritative and side-aware, so you can't open a door through the bodywork:
- The box you aim at must match your side — the outdoor box on foot, the indoor box when seated in that vehicle. Aiming at the far-side box (the one you couldn't reach) is ignored.
- That box must also have a clear line of sight — a foreign wall, the terrain or another vehicle in the way blocks it (the vehicle's own body doesn't count).
Because a vehicle's collision is a coarse convex hull that encloses both boxes, the sightline check
excludes the vehicle itself; the side of the box (outdoor vs indoor) is what distinguishes "I can see
this handle" from "it's on the far side". So place each box proud of its surface and assign the two
Shape exports. Leave them unassigned and the sightline gate is simply skipped (the door opens on look).
Place each handle where the door's handle is when the door is shut. At runtime the code re-parents
it under its door mesh (matched by Door Id), so the handle swings with the door — you can
look at it and press E whether the door is open or closed. (Without this, a handle left on the
body would stay at the shut position and the look-at ray would miss it once the door swings open.)
Door-gated seats — open the door before you can get in or out. Set the Door Id export on
the VehicleSeat to the door that guards it (e.g. the driver seat → front_l_door). Then:
- Open/close the door (look at the handle + E) on foot or while seated — so a driver/passenger can close it from inside.
- E boards only once that door is open. A closed door shows "Open the door first (aim at the handle)" instead of the board prompt.
- Y leaves only once that door is open — symmetric with boarding, so you can't step out through a shut door.
- A seat with an empty
Door Idboards / leaves directly (no gating).
The wind shuts an open door. Driving with a door open, the relative wind presses on it; past a
certain speed the door check gives way and the door slams (server-authoritative, replicated like any
door). The server checks it ten times a second on a moving vehicle (DoorWind): the dynamic
pressure ½·ρ·v² — with ρ the air of the planet at the vehicle's altitude
(AtmosphereProfile.air_density) — on the leaf as the flow sees it (area × sin of its opening
angle, a flat plate's drag 1.2), at half its width from the hinge, against the door check's torque.
Nothing to set on a new vehicle whose doors follow the naming: a door whose door_id ends with
_door (front_l_door) catches the wind, and its leaf (area, width) is measured from its own
mesh in the GLB; anything else (hatch_fl, a lid) does not. The VehicleDoorHandle's group
Wind overrides it:
| Setting | Default | Meaning |
|---|---|---|
wind_area_m2 | -1 | Area of the leaf: -1 = measured (a _door) or none (anything else); 0 = never; a value = that area. |
wind_shut_travel | (0, 0, -1) | Direction of travel whose airflow pushes it shut: a door hinged at its front edge is shut by driving forward. |
wind_width_m | -1 | Hinge to free edge (the push acts at half of it); -1 = measured with the area. |
wind_hold_nm | 25 | What the door check holds before it gives way. Twice the hold, 1.41 times the speed. |
On Tarsis 3 the truck's cab doors (measured: 1.15 m², 0.84 m wide, open at 60°) go at about
32 km/h at the reference sphere (air 1.27 kg/m³) and 41 km/h in the villages at 5130 m
(0.77 kg/m³). It stays on the server because a door is gameplay, not decoration: a shut door bars
boarding and leaving, and only the server decides doors. Simplifications: still air (no wind of its
own), a constant plate coefficient, no gust, no lee of the cab. The air's density needs the
atmosphere profiles to carry their pressure (surface_pressure_pa): regenerate them with the editor
menu DyingStar ▸ Rebuild atmosphere profiles after the system data changes; until then the
vehicle's air_density export (1.26) is used.
Both gates are server-authoritative: the client checks them for the prompt, but the server
re-checks on enter_vehicle / exit_vehicle and refuses through a shut door — and, to board, out of
reach or out of sight of the seat.
Collision (from the model)
The collision is generated by code, never hand-placed in the .tscn. Two sources, in order:
col_*meshes in the GLB (preferred) — the artist ships low-poly convex volumes namedcol_cab,col_bed_floor,col_bed_left… (see the modeler page). Each becomes one convex collider (create_convex_shape()) placed where it sits, and the source mesh is hidden (editor and game); several pieces keep the bed hollow. This matches the real shape — a parametric box can't. We use hand-authored convex pieces rather than automatic convex decomposition on the body mesh on purpose: a few cubes are clean, cheap and predictable, whereas decomposition spits out many messy shapes. Check them at runtime with Debug → Visible Collision Shapes.- Fallback boxes — if the model has no
col_*mesh,vehicle.gdbuilds the parametric chassis + bed boxes from the Body / Cab / Bed exports. With a real model these now follow the model's placement (so they sit under the body, not at the origin), but they remain a rough approximation — good for a blockout, not a finished model. Tune them with Debug → Visible Collision Shapes.
So: for a finished vehicle, author the collision in Blender (col_*); the boxes are just the
no-model-yet safety net.
Collision pieces have no mass — in Godot a collision shape never adds weight (and the visual
meshes never affect physics at all; only collision shapes do). The vehicle's mass is mass (empty)
+ occupants + cargo only (_refresh_mass), so col_* never inflate the load.
They would normally affect the center of mass (a RigidBody auto-derives its COM from its
collision shapes — a big col_cab at the front would pull the COM forward and the truck would
nose-dive). To prevent that, vehicle.gd pins the COM to the wheelbase centre (average wheel
position) plus the Center Of Mass Offset export — so handling stays balanced no matter how the
collision was modeled. Lower its Y for roll stability, or shift Z for a front/rear weight bias.
The generated colliders are invisible by default. To check them:
- In the editor top menu: Debug → Visible Collision Shapes (FR: Déboguer → Formes de collision visibles) — a checkbox, leave it ticked.
- Run the game (F5). It's a runtime overlay, so it must be enabled before running and only shows while the game runs.
- The colliders draw as cyan wireframes over the truck. You should see your
col_*shapes hug the body (and no oversized fallback box) — proof the model collision is in use. If you instead see one big box overhanging the model, your meshes aren't namedcol_*(so the fallback kicked in).
First-person view = the driver SitPoint
The in-cab camera sits on the driver seat's SitPoint (the same eye point used in game), so it
follows the real model — no blockout dependency. Place that SitPoint at head height in the cab,
facing forward (-Z).
Cargo — loading the bed
Two designer-placed zones drive cargo (no per-vehicle code):
cargo_bay— where cargo sits. Tune it via the Cargo export group (cargo_bay_size,cargo_bay_offset,max_payload,overload_immobilize). It is also the passive zone the player's detector overlaps to count their weight while standing in the bed.CargoLoadingZone— where dropping is allowed to load (it sticks). Add aCollisionShape3Dwith aBoxShape3DnamedCargoLoadingZone(or assign it to theVehicle's Cargo loading zone export), sized a bit larger than the bay so reaching over the wall counts. Its physics collision is turned off at runtime — it is only a zone marker. If unset, thecargo_baybox is used as a fallback.
The load counts toward max_payload, the overload limiter shown on the dashboard.
- Loading — carry a prop (a crate, a mined rock) and drop it inside the loading zone —
whether you stand in the bed or reach over from outside; the zone (not your position) decides. The
prompt reads
[F] Loadwhen dropping would load it (it sticks) and[F] Dropotherwise. The carrier hands it to the truck: the prop is frozen, parented to the vehicle so it rides along, and its mass is folded into the load. The bed never polls — loading happens on drop. - Placed in the bay — a loaded item is tucked entirely inside the
cargo_bay(using its real collision bounds, so any size or off-centre pivot fits) and rested on the bed floor, even when dropped from far / over the rim. - A held prop passes through vehicles while carried, so a held box can't shove or flip a much heavier truck — you load by dropping, not by ramming.
- Retrieve — aim at a loaded item and press F (
[F] Carry); it leaves the load and goes into your hands, like any carriable. - What counts as load:
- the props locked in the bed — each prop's weight is its
RigidBodymass; - on-foot players standing in the bed (their body mass), plus whatever they hold in their hands.
- the props locked in the bed — each prop's weight is its
- A mining rock's mass scales with its size: a whole rock keeps the mass set on its scene
RigidBody(the GameDesigner value); a cut piece weighs that scaled by its volume ratio (a half ≈ half, a quarter ≈ a quarter), so smaller pieces load the bed less. - Over
max_payloadthe HUD showsOVERLOADED; pastmax_payload × overload_immobilizethe vehicle won't move. Another vehicle is never loaded as cargo (no swallowing a truck with a truck). - Rollover — if the vehicle tips past
cargo_unlock_tilt_deg(default 65°), the bed unlocks and spills its whole load (GDD: the lock releases past a certain inclination).
Standing in the bed adds the weight, but walking in a moving bed (riding it on foot) is not supported yet — it needs client-side prediction. A moving truck currently leaves a walker behind.
How the load rides the truck (freeze mode)
A loaded crate must follow the truck rigidly — and it is a RigidBody3D, which the physics engine
places in global space, not by inheriting its parent's transform. So parenting a crate under the
truck is not enough; how it is frozen matters.
A frozen RigidBody3D has two modes:
FREEZE_MODE_STATIC(the default for a frozen body) behaves like aStaticBody: the physics server keeps rewriting its world transform every physics frame. Under a moving parent it stays put in the world while the truck drives off — the "cargo left behind at speed" bug.FREEZE_MODE_KINEMATICis animatable: the body follows its node's transform (parent_global × local), so as the truck moves, the crate rides with it.
The crate is set KINEMATIC on both sides: the server does it when it locks the crate, and each
client does it for the replica derived from the parent — when a prop is reparented under a
Vehicle it switches to KINEMATIC, and back to STATIC when it returns to the world (PropNet.apply_ride_freeze_mode). Nothing extra is replicated for this: the client reads it from parent_id, which already travels.
On top of that, two pins keep it perfectly stuck:
- Server pin — each physics frame the vehicle re-asserts every locked crate's constant local
pose in the bed (
_pin_locked_cargo), so a KINEMATIC body can't drift relative to the moving truck. The replicated local position therefore stays constant. - Client pin — each render frame the prop re-asserts that same local pose
(
PropNet.ride_pin), so the crate tracks the render-interpolated truck without stepping at the physics rate (which would jitter). A direct set, not a lerp.
Turn on Settings → General → Cargo debug to draw a green envelope around every crate that is really locked in a bed (i.e. reparented under the vehicle). An item merely resting loose in the bed gets none — a quick way to tell "stuck" from "just sitting there".
Brakes
The decelerate key (vehicle_decelerate, S) pressed against the motion is the service
brake: rolling forward it brakes, by how far it is pressed (a trigger half-way, half the brake), and
only once the vehicle is under 2 km/h does the same key back it up. The other way round,
vehicle_accelerate while rolling back brakes too. The engine stops pushing while it brakes. Space
(brake) brakes at full.
The brakes are set as a deceleration, not a force: brake_deceleration (m/s², default 7.0 ≈
0.7 g, what a real truck is built for). Every physics step the impulse each wheel may apply is worked
out from the vehicle's current mass, cargo included, so a loaded truck stops as hard as an empty
one. A fixed force could not: the same setting braked an empty truck at 5 m/s² and one carrying 3 t at
1.6. The tyres' grip still caps it (wheel_friction_slip_default, under the body's own gravity): ask
for more than they hold and the wheels lock and slide.
Hand brake
Vehicles spawn with the hand brake engaged (parked), and the driver toggles it with a long press
of Space under 3 km/h (released by throttle). It holds the vehicle still on flat ground and slopes by
braking the wheels and cancelling drift — not by freezing the body (freezing a VehicleBody3D
collapses the wheel suspension, so the wheels sink into the ground). It stays engaged after the
driver leaves. A moving vehicle left with no driver and no hand brake coasts to a stop on its own
(it never keeps driving off). Shown on the HUD and on the dashboard.
Head lights
Head lights are a drop-in like seats: add any Light3D (e.g. a SpotLight3D at the front
facing the vehicle's local -Z) to the group vehicle_light anywhere under the scene — no
code. The driver toggles them with L; the state is server-authoritative and replicated, so every
client sees them. Shown on the HUD ([L] lights) and on the dashboard. The truck ships with two
front SpotLights as the example.
L drives the head lights only; the player's torch has its own action, toggle_flashlight
(U, pad D-pad ↑).
Steering
The steer keys are a rate, not a position: a tap turns the wheels a little, holding turns them
further, and releasing leaves them where they are — the driver builds a turn up in taps, the way
one feeds a real steering wheel through the hands. Pressing the opposite direction winds them back
(a bit faster, steer_return_speed).
While rolling, the wheels drift back to straight on their own (a real wheel's caster):
steer_self_center_speed, scaled by speed up to steer_self_center_ref_kmh. Parked, they keep
their angle. The speed-sensitive lock (steer_speed_falloff_kmh / steer_min_ratio) still applies,
and pulls a held angle back in as the vehicle speeds up.
The maths lives in vehicle_steering.gd (VehicleSteering, pure and unit-tested); the vehicle copies
its Steering exports (see the Inspector reference below) into it every tick, so they can be tuned
live. Nothing new on the wire: the client still sends the steer axis, the server integrates it, and
steering is replicated.
Speed limiter
The driver turns it on/off with T and moves the limit by 5 km/h with Alt + mouse wheel
(between 5 km/h and limiter_max_kmh). The chosen limit is kept while the limiter is off, so T
brings the same one back. Server-authoritative: the client only asks (vehicle_limiter,
vehicle_limiter_step), the server checks that the sender is the pilot.
- Under the limit, it does nothing; over the last
limiter_taper_kmhthe engine eases off, so the vehicle settles on the limit instead of banging against it. - At the limit, the engine stops pushing.
- More than 1 km/h over it, the throttle counts as closed, so the usual engine braking (the one of a released pedal) brings the vehicle back down. This matters: the game models no drag at all — no air, and no rolling resistance while the pedal is down — so an engine that merely stopped pushing left a vehicle that was already over the limit rolling at that speed for ever.
- It never adds a brake: downhill the vehicle may still run over the limit; slowing down is the driver's job.
On the dashboard the limit is dimmed when the limiter is off, green when it is on, red
while it is holding the vehicle back. Replicated as limiter_on / limiter_kmh (and so persisted).
Odometer
Every vehicle counts the distance it has driven over its whole life — across sessions and server
restarts. The server adds up the moves of the vehicle's local position (relative to the planet it
drives on, which moves and spins), and skips a step when the parent changes (new frame) or when it is
too long to have been driven (a reset, a teleport). It is replicated as odometer_km in 0.1 km
steps, only on change — and being replicated is what makes the persistence service store it. At boot
the server takes it back through the same restore path as the doors. Shown on the dashboard.
If Horizon re-seeds a vehicle with a new uuid, that vehicle starts again from 0 km.
Adding replicated vehicle state — VehicleNetPart
The limiter and the odometer are parts (vehicle_net_part.gd): each one knows what it last sent,
writes only its changed keys (write_changes), and takes received keys back (read), remembering
them as sent so a value restored from the database is not sent straight back. Vehicle asks every
part in _replicate_transform, and hands every part the received data in client_channel_data_update
and server_adopt_state — so a new piece of vehicle state is a new part, not new lines in those
three functions. Parts are exposed as properties (vehicle.limiter, vehicle.odometer), not getters:
vehicle.gd must stay under gdlint's 60-public-method ceiling (gdlintrc).
A key a part writes must also be listed in vehicle_def.json — Horizon drops anything else in
silence: no error, the value simply never reaches the other clients nor the database.
Dashboard — optional in-cab screen
Show live data (speed, warning lights, power, batteries) on a screen in the cab using the generic 3D-screen pattern — see GUI on a 3D screen for the setup (SubViewport, ViewportTexture, and the Local-to-Scene / viewport-path gotchas).
Point the Gui3D's node_quad at a screen mesh in the GLB (e.g. ScreenFront, via Editable
Children on the model). Gui3D now applies its SubViewport as a texture by code when the mesh
has no material_override — so a model screen "just works", no hand-made ViewportTexture material.
The mesh still needs clean 0..1 UVs and its front face toward the driver (see the
Vehicle 3D models screen rules). The dashboard is read-only,
so node_area (touch) is optional.
The vehicle-specific part is only the UI script: put vehicle_dashboard.gd
(VehicleDashboard) on your UI scene's root (scenes/_universe/vehicles/vehicle_dashboard.tscn is
that root alone, instanced in the truck's SubViewport). It finds its owning Vehicle in the tree and
shows the generic Vehicle data each frame — so it is reusable by any vehicle, and the Vehicle knows
nothing about it. It builds its layout in code; the script is @tool only so that the editor shows a
still preview with sample values instead of an empty panel (its nodes have no owner, nothing of it is
saved into the scene).
Laid out to the screen's shape. The viewport takes the screen mesh's aspect
(Gui3D.surface_size()) at 400 px high: the truck's ScreenFront is a strip 54 × 8 cm, about
6.5:1, and an interface drawn at 1921 × 1112 was squashed almost four times into it.
One strip, read left to right, pictograms in place of words
(assets/_universe/vehicles/ui/dashboard_icons/, white, tinted by code):
| Zone | Shows |
|---|---|
| Driving | the speed, big; the odometer under it |
| Warning lights | headlights, handbrake, doors; speed limiter (with its limit), load (with cargo / payload kg) |
| Component bays | one box per bay, in bay order: green with the fitted part's pictogram (its spec's icon), grey and empty when free |
| Energy | the drive (4x4) over the motor, the power used and the rpm, each battery's charge |
A light that is off stays in place, faint, so the eye always finds it where it was. Colours follow
real dashboards: green on (headlights), amber caution (limiter on), red danger (handbrake
held, blinking when the truck is driven against it; limiter holding the truck back; overload). The
doors light is orange while a door or hatch is open and turns red at 80 % of the speed at which the
wind will shut the first open door (Vehicle.open_doors_wind_kmh, the DoorWind sum that does it).
Door alarm. DoorAlarm gives ONE beat to the doors light's blink and to a quiet beep, so the two
never drift apart: a beep once a second with a door open, then quicker and quicker from 80 % of the
shutting speed (one every 0.5 s) to the shutting speed itself (one every 0.12 s). The sound and its four
knobs (sfx_door_alarm*, the same as every vehicle sound, see Sfx3D) are on the dashboard scene's
root; it plays at the screen, heard in the cab and just around it. Engine off: no beep, like the rest
of the screen.
Power, not RPM. An electric truck has no meaningful RPM: the gauge shows the share of the
motors' full force the drive uses. The server knows it (Vehicle.power_share, set each tick by
_draw_energy) and replicates it as power, rounded to 5 % so it only travels when it changes
(vehicle_def.json in the game's items_def/ and in horizonserver's props, both).
Rear-view camera & mirrors
A live camera feed on an in-cab screen — a drop-in (scenes/_universe/vehicles/rear_camera.tscn),
reusable and client-only (no render on the headless server). Use it for a reversing camera and
for side / rear-view mirrors (the GDD allows "mirrors or relayed cameras"). Godot has no cheap
real reflective surface, so a mirror is just a camera too.
Setting one up in the editor — select the RearCamera, frame it with the Camera Preview, and
drag the model's screen mesh into its screen slot:

Setup (no code):
- Instance
rear_camera.tscnunder the vehicle. RearCamerais a Camera3D — place and frame it in the editor (gizmo / Preview), at the back looking behind (or at a side mirror, looking back/outward). It never renders to the player's view; a twin camera inside itsSubViewport(sharing the mainworld_3d) copies its pose + fov and renders the feed.- Set the
RearCamera'sscreento the surface that shows the feed. Use either a localMeshInstance3Dwith aQuadMesh, or a screen mesh from the GLB model (e.g.Recul_Screen,RetroL_Screen,RetroD_Screen) — select it via Editable Children on the model and drag it intoscreen. The feed material is applied at runtime and is one-sided (shows on the front face only). A blank screen = the face points the wrong way → fix the normals in Blender (front face toward the driver — see the Vehicle 3D models screen rules), and ensure clean0..1UVs. (Themirrorflag below flips left/right; it does not fix a wrong-facing or rotated face.) mirror: off = a reversing camera (true view); on = a mirror (reverses left/right).resolution: match its aspect to the screen quad to avoid stretching.max_distance/refresh_hz: see Performance below.
Each RearCamera renders the whole world again, so a rear cam + two mirrors = three extra renders.
Two built-in limiters keep this cheap on weak GPUs (the screens still stay live for everyone):
max_distance— beyond this distance from the viewer the feed stops rendering (keeps its last image; it is unreadable from afar anyway).0disables the distance cull.refresh_hz— how many times per second the feed re-renders (default 20, not the full frame rate).0renders every frame.
Also keep resolution small.
Screens power off with the engine
The rear-view screens and the dashboard go dark when the engine is off, and light up again on
ignition — so a parked truck reads as powered-down, not frozen. Each screen reads the vehicle's
replicated is_engine_on() on its own (the Vehicle knows nothing about its screens): a rear
camera swaps its feed for a black image and stops rendering (no wasted GPU) while off, and the
dashboard multiplies its panel to black. Since the engine state is replicated, every nearby player
sees the same on/off. No setup — it's automatic on any screen wired as above.
Inspector reference — every @export
Every knob below lives on the root Vehicle node, grouped in the Inspector exactly as shown.
Listed values are the defaults — truck.tscn overrides some of them. Most apply live.
Changing a dimension (Body / Cab / Bed / Wheels) rebuilds
the procedural blockout; with a real GLB model the blockout visual is skipped, but the collisions,
cameras and cargo bay still use these numbers.
Drive
| Property | Default | Role |
|---|---|---|
torque_response | 2.5 | How fast torque ramps to the throttle (1/s). Lower = gentler launch (keeps a heavy vehicle from leaping off the line). |
brake_deceleration | 7.0 (0.5–15) | Deceleration (m/s²) the brakes ask of the vehicle at its current mass, empty or loaded (the truck: 10). The tyres' grip still caps it. |
engine_brake | 6 | Engine braking + rolling resistance applied per wheel while coasting (no throttle, no brake) — without it the truck rolls forever. Keep it well below the brakes: it bleeds speed off gently. |
handbrake_hold | 30 | Parking-brake damping (m/s per s) above the release speed. A real collision impulse exceeds this, so a hit parked truck still gets pushed (then re-settles). |
handbrake_release_speed | 2 | Horizontal speed (m/s) below which the engaged hand brake fully cancels motion — a parked truck must not move from a player bump or a gentle slope. |
drive_mode | ALL | Driven wheels: FRONT (FWD), REAR (RWD) or ALL (4×4). |
| Mass | (node) | Not an @export, and not authored: the vehicle writes it as empty_mass + fitted components + cargo. |
center_of_mass_offset | (0,0,0) | COM offset (m) from the wheelbase centre. We pin it ourselves (else the col_ pieces drag it forward → nose-dive). Lower Y for roll stability, shift Z for a weight bias. |
Steering
| Property | Default | Role |
|---|---|---|
max_steer_deg | 30 | Maximum front-wheel steering angle (degrees). |
steer_speed | 1.6 | How fast a held key turns the wheels further (rad/s). Lower = more progressive (the truck uses 0.2). |
steer_return_speed | 3.0 | How fast a held key winds the wheels back when it points against the current angle (rad/s). The truck uses 1.0. |
steer_self_center_speed | 0.3 | Hands-off drift back to centre while rolling (rad/s), reached at steer_self_center_ref_kmh. 0 = the wheels never move on their own. |
steer_self_center_ref_kmh | 50 | Speed at which the hands-off drift reaches steer_self_center_speed; it scales linearly below. Parked = no drift. |
steer_speed_falloff_kmh | 80 | Above this speed the steering lock shrinks (toward steer_min_ratio) — agile when slow, stable when fast. 0 disables. |
steer_min_ratio | 0.35 | Fraction of the max steer angle still available at/above steer_speed_falloff_kmh. |
Speed limiter group:
| Property | Default | Role |
|---|---|---|
limiter_step_kmh | 5 | One step of the selector (Alt + wheel), km/h. |
limiter_max_kmh | 130 | Highest selectable limit, km/h. |
limiter_default_kmh | 30 | Limit the selector starts on, km/h. |
limiter_taper_kmh | 3 | The engine eases off over this many km/h below the limit. |
Steering wheel (visual) sub-group — cosmetic only, no effect on driving:
| Property | Default | Role |
|---|---|---|
steering_wheel_ratio | 8.0 | Radians the volant turns per radian of front-wheel steer (real cars ~8–16 lock-to-lock). |
steering_wheel_axis | (0,0,1) | The steering-wheel mesh's own local spin axis (its disc/column axis). Try a cardinal axis until the volant spins in-plane. See Vehicle 3D models. |
Body / Cab / Bed (blockout dimensions, meters)
They size the procedural blockout, and with a real GLB model they still place the cameras, the
exit point and the fallback collision. Changing any of these rebuilds the blockout live. In the
Inspector they sit in the Body, Cab and Bed groups; cab_cutout_*, wheel_width and
track_width sit in Blockout (no 3D model).
| Property | Default | Role |
|---|---|---|
body_length | 4.6 | Blockout body length. |
body_width | 2.0 | Blockout body width. |
body_height | 0.5 | Blockout body height. |
cab_length | 1.5 | Blockout cab length. |
cab_height | 1.9 | Blockout cab height. |
cab_cutout_size | (2.255, 0.969, 0.744) | Windshield / door opening carved from the body (a CSG subtraction inside the body combiner). |
cab_cutout_offset | (-0.017, 1.454, -2.062) | Position of that cutout. |
bed_wall_height | 0.7 | Blockout bed wall height. |
Wheels
| Property | Default | Role |
|---|---|---|
wheel_radius | 0.55 | Wheel radius (blockout + physics contact). |
wheel_width | 0.35 | Blockout wheel width. |
wheelbase | 3.0 | Distance between front and rear axles. |
track_width | 1.55 | Distance between left and right wheels. |
suspension_rest | 0.4 | Suspension rest length (how far the wheel hangs below its mount) → ride height. |
suspension_stiffness | 22 | Spring stiffness (a 1 t truck ~20–25). Too high + any ground penetration launches the body. |
suspension_max_force | 15000 | Max force each suspension can push (N). Must exceed the loaded static load per wheel (~5600 N at 2.3 t) with margin, or the bed bottoms out when full. |
Real 3D model (GLB drop-ins)
Optional — present a GLB (group vehicle_model, or named wheel meshes) and the blockout visual is skipped.
| Property | Default | Role |
|---|---|---|
real_wheel_spin_axis | (1,0,0) | Spin axis of a real wheel mesh, in the mesh's local space (its axle). A Blender +Y-up export with transforms applied usually leaves the axle on local X. Change if the wheels spin around the wrong axis (e.g. "on their edge"). |
door_open_angle_deg | 75 | Fallback only (door with no Blender animation clip): open angle (degrees), swung over door_swing_secs… |
door_hinge_axis | (0,1,0) | …about this local hinge axis. The real swing is normally authored in Blender. |
door_swing_secs | 0.45 | Fallback only: how long that swing takes (s). |
Chassis physics
The chassis's own contribution to the drive model. The motor side of it lives on the component
(power_w, torque_nm, efficiency, propulsion, gearbox) — see
Vehicle components.
| Property | Default | Role |
|---|---|---|
empty_mass | 1425 | Mass of the bare chassis (kg), modules and payload excluded. Declared, never captured from mass at _ready — see the warning on the components page. |
max_engines | 3 | How many engines this chassis will run. Bays are generic; the chassis caps the count. -1 = no limit. |
factory_components | (empty) | What the chassis leaves the works with (engines, batteries…). The server turns each entry into a real, removable part in a free bay, with a deterministic uuid so a restart upserts instead of duplicating. |
pump_efficiency | 0.8 | Hydraulic pump efficiency — part of the chassis transmission, not of the motor. |
hydraulic_efficiency | 0.8 | Hydraulic circuit efficiency. pump × hydraulic = 0.64 on the MVP truck. |
torque_factor | 1.0 | Chassis torque multiplier applied to the sum of the motor torques. No efficiency term on this path — that is the design sheet's own rule. |
rolling_coefficient | 0.01 | Cr, rolling resistance. The sheet quotes 0.001 on good tarmac to 0.3 on soft sand; per-surface Cr is a natural follow-up. |
rolling_factor | 1.0 | Chassis multiplier on Cr. |
drag_coefficient | 1.0 | Cx. Only matters once a chassis is drag-limited rather than engine-limited, which the MVP truck is not (its aero ceiling is 55.9 m/s against a 28.8 m/s motor ceiling). |
frontal_area_m2 | 3.0 | S, frontal area. Same caveat. |
air_density | 1.26 | Air density (kg/m³) used only where the planet has no atmosphere profile; otherwise it is read from the planet's AtmosphereProfile at the vehicle's altitude (air_density_now()). |
reverse_max_ratio | 0.15 | Reverse top speed as a fraction of the forward top speed. A fraction, not a km/h, so it cannot contradict whatever engines are fitted. |
speed_taper_kmh | 10 | Width of the band below top speed over which the drive tapers off. The sheet is an equilibrium model, not a torque curve: used as one it has no constant-power region, so the force would fall from 100 % to 0 in 0.1 km/h. Nothing else holds the speed down (no drag is simulated), so downhill at full throttle there would be no limit at all. |
Top speed is applied as min(motor, aero), and on the MVP truck the aero ceiling never bites. The
day a chassis is drag-limited, the real force belongs in _integrate_forces — and it deserves its
own commit, because it also changes how a vehicle slows down when coasting.
Cargo
| Property | Default | Role |
|---|---|---|
max_payload | 1300 | Max payload (kg) before overloaded (load limiter). ⚠️ A fitted component is not payload: its mass counts towards the vehicle, or a truck could be immobilised for being overloaded by its own engines. |
overload_immobilize | 1.2 | Above max_payload × this the vehicle is immobilized (1.2 = +20 %). |
cargo_bay_size | (2.0, 1.5, 3.1) | Size of the cargo-bay box. A massive body that comes to rest inside is absorbed (its mass added once). Also the on-foot bed-walker zone — a player standing inside adds their weight, so fit it to the real bed (too low/large counts someone standing beside the truck). |
cargo_bay_offset | (0.0, 1.0, 0.75) | Centre of the cargo-bay box, relative to the vehicle origin. |
cargo_loading_zone | (unset) | Optional designer box (CollisionShape3D + BoxShape3D) marking where a dropped item may load. Unset → falls back to the cargo-bay box. Its physics collision is disabled at runtime (zone marker only). |
cargo_settle_speed | 0.6 | A dropped body locks into the bed once it slows below this (m/s). |
cargo_unlock_tilt_deg | 65 | Tilt (° from upright) past which the bed unlocks and spills its load (a rollover drops the cargo). 0 disables. |
debug_show_cargo_bay | true | Show a translucent box marking the cargo-bay zone (turn off on shipped vehicles). |
Debug
| Property | Default | Role |
|---|---|---|
debug_color_driven_wheels | true | Tint the driven wheels green (idle wheels stay dark) to see FWD / RWD / 4×4 at a glance (turn off on shipped vehicles). |