Skip to content

v0.2 — multi-vehicle shared-world runtime

Status: in progress (started 2026-06-05). Decision recorded in V0.2_PLAN.md.

Decision

One vdsim_realtime process holds N vehicles in a shared world, stepped on one clock, so vehicles share the environment and (later) can interact. Rejected N independent plant processes because in-engine collision needs all bodies in one frame at the same instant with contact forces fed back inside the integration step — async free-running UDP processes cannot do that. Collision physics itself is a future TODO (below); the runtime is built collision-ready now.

Scope & control model (v0.2)

  • No runtime spawn/despawn. The vehicle set is fixed at simulator-setup time (the scenario YAML, loaded before the run starts). The runtime never adds or removes vehicles mid-run. (Dynamic spawning is a later concern.)
  • Manual drive targets the selected vehicle. A driver/algorithm CMD carries a vehicle_id; the runtime applies it to exactly that vehicle. Each vehicle holds its own latched command + ZOH timeout, independent of the others.
  • Multiple clients on one server. The CMD socket accepts datagrams from any source, so two (or more) GUIs/clients can connect to one runtime and each drive a different vehicle_id. STATE is delivered to every active client (see subscribers below). This is the racing feature: GUI A drives vehicle 0, GUI B drives vehicle 1, both see both cars, they race. No ownership lock in v0.2 (last CMD per vehicle_id wins); soft ownership can be added later if needed.

Subscribers (multi-client STATE delivery)

  • The runtime keeps a set of subscriber endpoints and sends every vehicle's STATE to all of them each tick.
  • Auto-subscribe from CMD source: when a CMD arrives from a new (ip,port), add it as a STATE destination and refresh its last-seen time; evict after a timeout (e.g. 2 s silent). So any controlling client automatically receives state — no explicit registration needed for drivers.
  • A lightweight subscribe/heartbeat (a CMD with throttle/brake/steer = 0, or a dedicated keepalive) lets pure spectators receive STATE without driving.
  • Backward compat: the single fixed --state-ip/--state-port destination stays supported as a pre-seeded subscriber (today's GUI keeps working unchanged).

Wire protocol — VDS1 v4

Add a vehicle_id to address vehicles on a single socket. The 24-byte header already has a _pad uint32 right after seq; repurpose it as vehicle_id (uint32). No header or payload size change — only kVersion 3 -> 4 and the pad becomes a real field. CMD and STATE both flow through write_header, so both carry vehicle_id for free; kCmdBytes/kStateBytes are unchanged.

  • STATE: server emits one packet per vehicle per tick, each tagged vehicle_id.
  • CMD: client tags each command with vehicle_id; server demuxes and ZOH-holds per vehicle (per-vehicle cmd_timeout).
  • Single-vehicle is just N=1 with vehicle_id = 0 — existing flows keep working.
  • Mirror the change in cosim/protocol.py and bump the version check there too.

Runtime — cosim/realtime_server.cpp -> world

  • Hold std::vector of per-vehicle entries: { IVehicleDynamics, State, last Cmd, cmd_recv_time, vehicle_id, spawn pose }.
  • One shared environment (IContactProvider) and one shared clock; step every vehicle each tick with the same dt.
  • Per tick: drain the CMD socket, demux by vehicle_id -> per-vehicle ZOH, and add each CMD's source to the subscriber set; step each vehicle; [future] run the contact-coupling pass; emit each vehicle's STATE to every subscriber.
  • Config (fixed at setup, no runtime spawn): a scenario YAML listing vehicles — each with vehicle.yaml, tire.yaml, level, spawn {x0,y0,yaw0,vx0}, and vehicle_id. Keep the current flat-flag CLI as the N=1 shortcut.

GUI — gui/server.py

The data layer already keys by vehicle id (self.ports[vid], live_vid, /api/io and telemetry take a vehicle field). Changes: - CosimBridge connects to ONE runtime, receives the multiplexed STATE and demuxes by vehicle_id into per-vehicle buffers; renders all vehicles. - Control relays CMD tagged with the selected vehicle_id (the car this GUI drives). Two GUIs pointed at the same runtime, each with a different selected vehicle, race against each other. - The runtime can spawn locally (single-GUI default) or be an already-running shared server that multiple GUIs attach to (racing) — the GUI just needs the runtime's host/cmd-port. Scene UI (WS3) grows the vehicle tree / selector.

Collision — FUTURE TODO (deferred)

Where it will go and what it needs, so the runtime is built to accept it: - A contact-coupling pass after per-vehicle force/derivative computation and before (or within) the integration step, so contact forces are applied in the same step — this is why the shared-world single-process design was chosen. - Needs: per-vehicle body geometry (footprint / OBB), broad-phase (AABB sweep) + narrow-phase (OBB overlap / GJK), and a contact model (penalty spring-damper or impulse/LCP) feeding equal-and-opposite forces into each body's equations. - Fidelity is its own decision when scheduled (lightweight impulse vs full rigid-body); VDSim today has only tire-road contact, so body-body contact is a sizable new module. CARLA (PhysX) remains an alternative if collision is delegated. Not in the v0.2 multi-vehicle foundation scope.

Implementation increments

  1. VDS1 v4: vehicle_id in header (cosim_protocol.hpp + cosim/protocol.py), version 3 -> 4, round-trip test. (foundation — DONE)
  2. Runtime world: N vehicles in realtime_server.cpp, shared env + clock, scenario YAML config, CMD demux by vehicle_id, subscriber set (auto-subscribe from CMD source + timeout), STATE to all subscribers.
  3. GUI: connect to runtime, demux states, control the selected vehicle_id, render N; support attaching to an already-running shared runtime (racing).
  4. (parallel) subsystem modules — see V0.2_SUBSYSTEMS.md (brake / steering / drivetrain / suspension as pluggable interfaces). Independent of 2-3.
  5. (future) collision module per the section above.

Backward compatibility

N=1 + vehicle_id=0 reproduces v0.1 behaviour. The protocol size is unchanged, so only the version gate moves; old v3 peers are rejected by the version check (as before across bumps).