Skip to main content

Vehicle components (engines, and what comes next)

Read the vehicle page first

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​

FileWhat it is
scenes/_universe/vehicles/vehicle_component_spec.gdVehicleComponentSpec — what any component is: display_name, kind (ENGINE / BATTERY / TANK), tier, mass_kg, scene_path.
scenes/_universe/vehicles/vehicle_engine_spec.gdVehicleEngineSpec extends VehicleComponentSpec — what an engine is: power_w, torque_nm, efficiency, propulsion, gearbox fields.
scenes/_universe/vehicles/specs/engine_t1.tresThe T1 itself: 100 kW, 600 N·m, efficiency 0.9, 25 kg.
scenes/_universe/props/vehicles/engine_t1.tscnThe object in the world: a RigidBody3D with a PropSync (type_name = "vehicle_component"), carriable like a hauling crate.
scenes/_universe/vehicles/vehicle_component_slot.gdVehicleComponentSlot — a bay, placed by the designer. It also owns what it means to sit in it.
scenes/_universe/vehicles/vehicle_component_bays.gdVehicleComponentBays — the bookkeeping for one vehicle: what exists, what is fitted, what it adds up to.
scenes/_universe/vehicles/vehicle_drive_spec.gdVehicleDriveSpec — 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.

QuantityFormulaMVP truck, 3 × T1
Mechanical powersum(P_elec × efficiency) × pump_eff × hydraulic_eff172.8 kW
Mechanical torquesum(torque) × torque_factor — no efficiency term1800 N·m
Angular speed w_maxP / C96 rad/s = 917 rpm
Top speedw_max × R_wheel103.7 km/h
Tractive forceC / R_wheel6000 N
Rolling resistanceCr × rolling_factor × m × g103 N empty
Climbable slopeasin((F − F_roll) / (m·g))33°
Max acceleration(F − F_roll) / m3.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.

MotorsForceAcceleration0–100 km/hTop speed
00 N——cannot move, cannot start
12000 N1.26 m/s²22.0 s103.7 km/h
24000 N2.60 m/s²10.7 s103.7 km/h
3 (nominal)6000 N3.93 m/s²7.1 s103.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 WHEEL

Godot 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.

PropertyDefaultRole
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_range0.5How 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.

Never parent a physics body under a hatch mesh

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 STATIC frozen 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.

Two of the truck's hatches are inside the cargo loading zone

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 captured

It 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, weight on zone 0; scenename on both zones.
  • vehicle_def.json gains components on zone 0: the bay-to-part table {bay name: component uuid}, built exactly like the existing seats.
Deploy Horizon first

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:

LevelHoldsLives
slot.occupantthe runtime truthserver memory
VehicleComponent.slot_idthe persistence linkreplicated and persisted, on the part
componentsthe replicated viewderived 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_drive turns the force applied into the share of the motors' torque requested, and the battery pays discharge_j_per_nm_s × torque × Δt — 127 J per N·m per second, an @export on 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_engine refuses 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 in vehicle_component_def.json, zone 0), published in 0.1 kWh steps. A gauge (bar + 35.1 / 50.0 kWh) is painted under the pictogram (ComponentFace lays 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 icon is 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: ComponentFace lays 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 through ChargingZone._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.

SceneRun byOffersWhere
component_dispenser.tscnARES Hauling (yellow)vehicle parts (scenes/_universe/vehicles/specs/)garage
food_dispenser.tscnARES Energy (green)rations, 3 per delivery (props/food/dispenser_items/)villages
tool_dispenser.tscnARES Mining (orange)the mining tool, one per playercargo 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 overriding refusal(actor) (ToolDispenser).
  • Brand (AresBrand): the 9 subsidiaries and their colours, one place. The machine paints its brand_accent pieces, swaps every brand_logo sprite for the subsidiary's logo (the ARES mark ares_logo_mark.png tinted, the subsidiary's name under it in Xolonium), and its screen takes the colour.
  • Catalogue (DispenserCatalog.all(dir)): every item .tres of a folder — a VehicleComponentSpec, or a DispenserItem (display_name key, icon, scene_path, count). A new item is a new .tres. An item must be a networked prop (a PropSync whose type has a *_def.json in horizonserver).
  • Server: update_screen only accepts an item of the catalogue (it never loads a path the client sent) and refuses while a delivery runs or when the variant's refusal() says so — the reason is shown on the screen of everyone near (screen_event "refused"). A delivery lasts delivery_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 at TrayMarker (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 a PropSync of the generic simple_building type, and poi_villages.gd spawns 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, an AmbientLoop3D) 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.

Known limitation: orphan rows

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:

  1. Write a VehicleComponentSpec subclass with whatever the part needs (as VehicleBatterySpec did). No engine code moves.
  2. Add a .tres for the tier and a .tscn for the object (copy engine_t1.tscn: a RigidBody3D on the prop layer, a PropSync with type_name = "vehicle_component" and enable_carry = true, a mesh, a collision shape, and an Area3D on the interactable layer so the interact ray sees it).
  3. Register the scene in SpawnCatalog and in props_scene on both server/client.gd and server/server.gd.
  4. 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.

No CSGBox3D for the mesh

A 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
SuiteAsks
test_vehicle_drive_specDoes the model reproduce the sheet's five reference vehicles?
test_truck_factory_fitDo the truck's bays, limits and factory engines agree with the scene?
test_engine_componentIs the part carriable, aimable, and does its GORC type match the def?
test_engine_force_per_wheelPer 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.