Vehicle components (engines, and what comes next)
This page assumes you know how a vehicle is built and replicated — see Adding a vehicle.
A component is a part you can hold in your hands and bolt into a vehicle: today a T1 electric motor, tomorrow a battery, a tank, a set of tyres. It is an ordinary carriable prop that happens to fit in a bay, and what is fitted is what decides how the vehicle performs.
That last point is the whole idea. Before this, a truck's performance was typed into the editor —
engine_power = 850, max_speed_kmh = 55, both chosen by ear. A vehicle could not be upgraded,
could not break down, could not be repaired. Now: pull one motor out of a truck and it measurably
loses a third of its pull; pull all three and it will not move, and will not even start.
The pieces
| File | What it is |
|---|---|
scenes/_universe/vehicles/vehicle_component_spec.gd | VehicleComponentSpec — what any component is: display_name, kind (ENGINE / BATTERY / TANK), tier, mass_kg, scene_path. |
scenes/_universe/vehicles/vehicle_engine_spec.gd | VehicleEngineSpec extends VehicleComponentSpec — what an engine is: power_w, torque_nm, efficiency, propulsion, gearbox fields. |
scenes/_universe/vehicles/specs/engine_t1.tres | The T1 itself: 100 kW, 600 N·m, efficiency 0.9, 25 kg. |
scenes/_universe/props/vehicles/engine_t1.tscn | The object in the world: a RigidBody3D with a PropSync (type_name = "vehicle_component"), carriable like a hauling crate. |
scenes/_universe/vehicles/vehicle_component_slot.gd | VehicleComponentSlot — a bay, placed by the designer. It also owns what it means to sit in it. |
scenes/_universe/vehicles/vehicle_component_bays.gd | VehicleComponentBays — the bookkeeping for one vehicle: what exists, what is fitted, what it adds up to. |
scenes/_universe/vehicles/vehicle_drive_spec.gd | VehicleDriveSpec — the sizing model, translated 1:1 from the design sheet. Pure, no nodes. |
A spec is data, a component is an object. The .tres says what a T1 is; the .tscn is the
thing lying on the floor that you can trip over. Keeping them apart is what lets a battery ship
later without a line of engine code moving.
The drive model
VehicleDriveSpec is the sizing sheet and nothing else — no gameplay fudge, no clamp. It is
unit-tested against the sheet's five reference vehicles.
| Quantity | Formula | MVP truck, 3 × T1 |
|---|---|---|
| Mechanical power | sum(P_elec × efficiency) × pump_eff × hydraulic_eff | 172.8 kW |
| Mechanical torque | sum(torque) × torque_factor — no efficiency term | 1800 N·m |
Angular speed w_max | P / C | 96 rad/s = 917 rpm |
| Top speed | w_max × R_wheel | 103.7 km/h |
| Tractive force | C / R_wheel | 6000 N |
| Rolling resistance | Cr × rolling_factor × m × g | 103 N empty |
| Climbable slope | asin((F − F_roll) / (m·g)) | 33° |
| Max acceleration | (F − F_roll) / m | 3.93 m/s² |
Note the sum: over the motors actually fitted, not "× count". Mixing a T1 and a T2 costs zero
extra lines. And efficiency belongs to the motor (a T2 may be better), while
pump_efficiency / hydraulic_efficiency belong to the chassis — that is its transmission.
The motor count does not change the top speed
w_max = P / C, and both are proportional to the number of motors, so it cancels. That is
physically right — identical motors in parallel spin at the same speed and add their torque — and it
is good design: motors are a question of pull and payload, not of speed.
| Motors | Force | Acceleration | 0–100 km/h | Top speed |
|---|---|---|---|---|
| 0 | 0 N | — | — | cannot move, cannot start |
| 1 | 2000 N | 1.26 m/s² | 22.0 s | 103.7 km/h |
| 2 | 4000 N | 2.60 m/s² | 10.7 s | 103.7 km/h |
| 3 (nominal) | 6000 N | 3.93 m/s² | 7.1 s | 103.7 km/h |
Top speed follows the motor tier, the chassis torque_factor and the wheel radius — not the
number of motors. If you ever see the top speed move when you add a motor, w_max is wrong.
engine_force is applied PER DRIVEN WHEELGodot applies VehicleBody3D.engine_force to each wheel flagged use_as_traction, so the
sheet's total force is divided by the driven-wheel count before it is handed over. Getting this
wrong is a factor of four on a 4×4 and nothing would report it — the truck would simply
accelerate four times too hard, and someone would "fix" it by tuning a magic number back in. So it
is measured, not assumed: test/integration/test_engine_force_per_wheel.gd runs two identical
rigs, one 4WD and one 2WD, and compares them. Measured ratio: 1.90 (2.0 = per wheel, 1.0 = whole
body).
Bays
A bay is a VehicleComponentSlot, a Marker3D dropped under the vehicle. Nothing has to be
detected: the player fitting a part is aiming with the carry placement ray, which already resolves
a world point, and the vehicle picks the nearest free bay to it. A marker gives the editor gizmo for
free and costs nothing at runtime — no broad-phase entry, no monitoring pass.
| Property | Default | Role |
|---|---|---|
door_id | "" | The hatch guarding this bay — the door_id of a VehicleDoorHandle on the same vehicle. Empty = open access. Same spelling as VehicleSeat.door_id. |
snap_range | 0.5 | How near the drop point must land (m) for this bay to claim the part. |
A bay does not decide what goes into it. The truck's four hatches are four identical boxes;
typing a kind into each would freeze game design into a scene, and a bay's node name is the key the
replicated and persisted table is written with — it has to describe where the bay is, nothing
else. How many engines a chassis will run is the chassis's business: max_engines on the
Vehicle. A refusal comes back as a sentence ("This chassis takes 3 T1 Electric Motor"),
because a refusal the player cannot read is indistinguishable from a bug.
The truck's hatch meshes carry a non-uniform scale (0.175, 0.225, 0.325). Jolt handles a
non-uniformly scaled shape badly, and a RigidBody3D reparented under one inherits it. Bays are
direct children of the vehicle, exactly like SeatDriver and HandleFL.
Fitting and removing
Fitting extends the existing drop path rather than adding a network action: the gesture we
wanted — aim at the bay, press E — is putting an object down. CarryPlacement.resolve()
already follows your gaze and snaps to shelf slots; bays are one more candidate. Three lines
client-side instead of a full round trip, and the anti-cheat checks (_can_see,
_is_blocked_by_geometry) are already in place. Removing is free: the part is in the carriable
group, so [E] Carry takes it back out.
Both are done standing: the server refuses a grab while crouched or prone (_stance != 0), and
crouching with something in your hands drops it. At a part, the carry prompt says so in red — Stand
up to fit or remove a part (PlayerServer.grab_prompt returns "stand") — where it used to show
nothing and E simply did nothing. A crate still shows no prompt there.
The bay owns what it means to sit in it — VehicleComponentSlot.seat():
- frozen KINEMATIC (a
STATICfrozen body gets its world transform rewritten every physics frame instead of riding its parent); - excepted from the vehicle's own collision, so it cannot shove the truck it is bolted into, while keeping its collision layer so you can still aim at it to take it back;
- seated on the bay rather than left where it happened to land — a fitted part is at its bay by definition, and a freshly spawned one has usually fallen a little before anyone gets to it.
That one call is used by all three paths that need it — fitting by hand, restoring from the database at boot, and mirroring the replicated table on a client. They used to spell the same six lines out each, and the one that drifts is the client, where the part then falls out of a truck the server is holding pinned.
So a part put down there would be swallowed as freight unless something says otherwise. The rule is about state, not type: a loose component is ordinary cargo and loads into the bed like a crate (hauling a spare motor is the point of the feature); only a fitted one is refused. The bay is also tested before the bed on the drop path.
A fitted component is deliberately not in the bed's _locked_cargo, which has a pleasant side
effect: a bolted-in motor is not thrown out by the rollover spill. A bolted engine stays bolted.
Mass
vehicle mass = empty_mass + sum(fitted component mass_kg) + cargo
A component's mass is part of the vehicle, never of its payload. Routed through
get_cargo_mass() it would eat the load limiter's budget and could immobilise a truck for being
overloaded by its own engines. On the MVP truck: 1425 kg bare chassis + 3 × 25 kg = the sheet's
1500 kg mVide, and max_payload = 1200 gives 2700 kg fully laden = mChargé.
empty_mass is declared, not capturedIt is an @export, not a snapshot of mass taken at _ready. A persisted vehicle is given its
replicated mass before _ready runs — create_generic_object applies the channel data first,
and uuid is still empty at that point, so the "the server never applies channel updates" guard
does not fire. The old snapshot therefore captured chassis + modules + whoever sat in it last
session, and the truck grew heavier at every single reload.
Replication and persistence
Two GORC definitions, in horizonserver/ds_genericprops/props/:
vehicle_component_def.json—position,rotation,parent_id,slot_id,weighton zone 0;scenenameon both zones.vehicle_def.jsongainscomponentson zone 0: the bay-to-part table{bay name: component uuid}, built exactly like the existingseats.
A property missing from a definition is dropped without a word. Ship the game side first and
components — and the whole vehicle_component type — simply vanishes, with nothing in any log to
say so. scenename and parent_id are both mandatory (the client waits for both before it will
instance a prop), and weight is too (PropSync.send_properties_to_client() sends it for every
prop). See Replication definition files.
Three levels, no redundancy:
| Level | Holds | Lives |
|---|---|---|
slot.occupant | the runtime truth | server memory |
VehicleComponent.slot_id | the persistence link | replicated and persisted, on the part |
components | the replicated view | derived on every replication, so it cannot diverge |
slot_id on the part simplifies rather than costs. After a restart, persistence returns the
part parented to the vehicle with the right pose — but nothing says which bay it belongs to, so
it is neither frozen nor pinned and it falls straight through the truck. The shelf solves the same
problem geometrically, with a tolerance and a 600-frame retry window; naming the bay deletes that
function outright.
The client needs components too — it is not a convenience. _sync_powertrain() is only called
from the server's drive path, so without the table a replica keeps the helper's defaults and its
rev counter lies. For now one tier exists, so "uuid count × engine_t1.tres" is enough; the
day a T2 ships, the tier has to be replicated.
Batteries and energy
The T1 battery (battery_t1.tscn, VehicleBattery, spec VehicleBatterySpec in
specs/battery_t1.tres) is the motor's twin: same 0.4 × 0.3 × 0.6 m box, 25 kg, fits any of the four
hatches. Figures from the Calculateur Batterie sheet: 180 MJ (50 kWh), 1000 A, 500 V.
- One at a time (GDD 6.1).
VehicleEnergy(vehicle.energy) draws on the first battery that still holds a charge, in bay order, and moves on to the next when it runs dry. Bay order is the same everywhere, so the battery in use is not replicated. - The torque, not the speed. Each server tick,
_apply_driveturns the force applied into the share of the motors' torque requested, and the battery paysdischarge_j_per_nm_s × torque × Δt— 127 J per N·m per second, an@exporton the spec, to be validated in game (≈ 39 min at full throttle with one T1 motor). A slope, a load or a bad road ask for more torque per km. No torque (no throttle, hand brake, engine off): no draw. No current limit in the MVP: more motors empty it faster. - No charge, no start.
toggle_enginerefuses without energy; running dry stops the engine and puts the lights out. Lights, dashboard and rear cameras draw nothing, but need some charge. - The battery in use stays in while the engine runs:
VehicleComponentBays.removal_refused(), the one gate for every kind of part. - Charge on the part:
charge_j, replicated and persisted (whitelisted invehicle_component_def.json, zone 0), published in 0.1 kWh steps. A gauge (bar +35.1 / 50.0 kWh) is painted under the pictogram (ComponentFacelays out both); the cab dashboard shows one bar per battery with its percentage, the one in use at full strength. - One pictogram per kind: the spec's
iconis the dashboard's own drawing (assets/_universe/vehicles/ui/dashboard_icons/motor.png,battery.png), painted white on the part and shown green in the dashboard's bay boxes. Keep an icon trimmed to its drawing:ComponentFacelays a landscape icon along the long side of a face, and transparent margins make it look square. - Recharge:
VehicleBattery.charge(delta)follows the sheet's curve — full rate (500 kW) between 20 and 80 %, half rate below 20 % and above 80 %, empty to full in 504 s. The garage's charging bay hooks in throughChargingZone._charge_batteries(delta): every server tick it charges each loose battery lying in the zone; one fitted in a vehicle's bay is left alone.
Getting parts: the ARES vending machines
scenes/_universe/structures/dispenser/ — one machine (ItemDispenser, CSG blockout for now) for
every catalogue: pick an item on its screen, it drops into the hatch at waist height after a delivery.
| Scene | Run by | Offers | Where |
|---|---|---|---|
component_dispenser.tscn | ARES Hauling (yellow) | vehicle parts (scenes/_universe/vehicles/specs/) | garage |
food_dispenser.tscn | ARES Energy (green) | rations, 3 per delivery (props/food/dispenser_items/) | villages |
tool_dispenser.tscn | ARES Mining (orange) | the mining tool, one per player | cargo depot |
- A variant is an inherited scene: it sets
brand,catalogue_dir,title_key, and resizes the hatch (collision boxes,Tray, flap,TrayMarker,tray_box_*) when its items need it. A rule of its own (one tool per player) is a subclass overridingrefusal(actor)(ToolDispenser). - Brand (
AresBrand): the 9 subsidiaries and their colours, one place. The machine paints itsbrand_accentpieces, swaps everybrand_logosprite for the subsidiary's logo (the ARES markares_logo_mark.pngtinted, the subsidiary's name under it in Xolonium), and its screen takes the colour. - Catalogue (
DispenserCatalog.all(dir)): every item.tresof a folder — aVehicleComponentSpec, or aDispenserItem(display_namekey,icon,scene_path,count). A new item is a new.tres. An item must be a networked prop (aPropSyncwhose type has a*_def.jsonin horizonserver). - Server:
update_screenonly accepts an item of the catalogue (it never loads a path the client sent) and refuses while a delivery runs or when the variant'srefusal()says so — the reason is shown on the screen of everyone near (screen_event"refused"). A delivery lastsdelivery_s(5 s, against spam); it is announced to the players around (ScreenTarget.announce), then, in a physics frame, refused if anything lies in the hatch, else the part is spawned atTrayMarker(MarkerSpawn.spawn, the same code as the truck kiosk). A battery comes out full. - During the delivery every client near shows the screen's filling ring with the seconds left,
and runs the fan (
sfx_delivery); the part and the flap's sound come at the end. - Clients: each one looks into the hatch itself (the server's own box test) and lets the flap fall open, with a sound, when a part arrives — right for a late joiner too, with nothing sent.
- What fills the hatch (
MarkerSpawn.blocker): solids, players excepted (one leaning on the machine is not an item). The bodies of the networked node the machine belongs to are left out — the garage's model reaches into the hatch — but not a networked prop parented to it: a handed-out item is parented to the nearest networked node, the cargo depot for the tool, and leaving it out kept the flap shut on it. - Where it can stand: inside a building scene (garage, cargo depot), it comes with the building. In
a village layout (
ares_village_mining.tscn) it is spawned on its own: the machine carries aPropSyncof the genericsimple_buildingtype, andpoi_villages.gdspawns only a layout's networked props — without it, it was skipped as decoration and never seen in game. A village spawns its items once (is_spawned): one added to an existing world shows after a database purge. - Sound: an idle hum (
IdleHum, anAmbientLoop3D) heard within ~8 m, besides the hatch and the delivery fan. All three are set on the base scene and inherited by every variant. - Why waist height: the carry prompt is decided by the server's own aim ray, and the client has no wall collision. A part under an overhang near the floor could be picked up (the grab uses the part the client aimed at) but showed no prompt (the server's ray grazed the overhang).
Factory components
A vehicle comes out of its scene (the garage's trucks, garage.tscn) with empty bays — so none of
them would move.
factory_components: Array[VehicleComponentSpec] on the Vehicle is what the chassis leaves the
works with (the MVP truck: two T1 motors and a T1 battery), and the server turns it into real,
removable parts with
uuid = PropSpawn.stable_uuid("<vehicle uuid>:<bay name>")
Same seed, same uuid, so a restart upserts instead of piling on a duplicate — a direct answer to the "Horizon re-seeds and doubles the world" trap.
Horizon re-seeds vehicles with new uuids on every boot over a non-empty database. A vehicle that is replaced leaves its engines behind, keyed to a parent that no longer exists. Measured on a fresh world: 59 engine rows for 16 trucks instead of 48 — 48 attached to a live vehicle, 11 orphans. They are inert rows, not a gameplay bug, and the duplicate guard itself works. The underlying behaviour is Horizon's re-seed, which affects every prop type.
Adding a new kind of component
VehicleComponentSpec is the base class and the extension point:
- Write a
VehicleComponentSpecsubclass with whatever the part needs (asVehicleBatterySpecdid). No engine code moves. - Add a
.tresfor the tier and a.tscnfor the object (copyengine_t1.tscn: aRigidBody3Don the prop layer, aPropSyncwithtype_name = "vehicle_component"andenable_carry = true, a mesh, a collision shape, and anArea3Don the interactable layer so the interact ray sees it). - Register the scene in
SpawnCatalogand inprops_sceneon bothserver/client.gdandserver/server.gd. - Give the chassis its limit: one more branch in
VehicleComponentBays.limit_for().
The GORC type stays vehicle_component, so there is no new Horizon definition — that is the
point of a shared type. Only state of the part's own (a battery's charge_j) has to be whitelisted
in vehicle_component_def.json, in the game and in horizonserver.
CSGBox3D for the meshA CSG node only draws when it is the root of its CSG tree. Under a RigidBody3D it is simply
invisible — an object that is correct in every piece of data and absent from the screen. Use
MeshInstance3D + BoxMesh: same cube, and you can see it.
Testing
The main suites. CI runs the whole GUT suite (that step reports failures without blocking a merge); to run them by hand:
godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://test/unit -gexit
godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://test/integration \
-gselect=test_engine_force_per_wheel -gexit
| Suite | Asks |
|---|---|
test_vehicle_drive_spec | Does the model reproduce the sheet's five reference vehicles? |
test_truck_factory_fit | Do the truck's bays, limits and factory engines agree with the scene? |
test_engine_component | Is the part carriable, aimable, and does its GORC type match the def? |
test_engine_force_per_wheel | Per wheel or whole body? (integration — it runs real physics.) |
In game, the HUD carries the same instrument: the model's prediction and the measured acceleration side by side, with a 0–100 stopwatch. A model you cannot compare against a measurement is a model you have to take on faith. It can be hidden from Settings ▸ Debug ▸ Vehicle dashboard.