Architecture & Main Loop
The simulator is an ECS: all state lives in a single microecs.World (Struct-of-Arrays component pools). There are no entity classes — a cube / mesh / robot is a spawn function plus a set of components (pure data), and behaviour is systems (functions over the pools) and plugins. "What is a robot?" is a query (world.query(HasFPV, HasCollision)), not a type.
Terminology: ticks and subticks
- Tick = render tick. One iteration of the main loop (~60 Hz). Events are gathered, resolved, and responded to once per tick.
- Subtick = physics iteration within a tick. Multiple subticks run per tick (up to 15, due to spiral of death clipping to 0.25s and 60hz) to maintain stable physics integration regardless of frame rate.
Events operate at tick granularity; physics runs at subtick granularity. Reactions to physics — did a move hit a wall, how far is the mission from its target — are read back once per tick, after all subticks, straight from the ECS components (HasCollision.is_colliding), not plumbed through each subtick. Subtick precision matters for position/velocity integration; event reactions can afford 1-tick latency (~16ms).
Main loop
The whole simulator is one loop. Setup builds the ECS world, loads the chosen plugins, opens the TCP listener, and wires them into a Simulator; then every iteration is one render tick (~60 Hz):
# --- setup ---
world = build_world() # scene objects + robots, all spawned as ECS entities
state = SimState(...) # collision-grid size, rolling tick-timing stats, ...
plugins = PluginsManager(["manual_move"]) # whichever --plugins you enabled (manual_move / trajectory_mission / race / ...)
network = ConnectionManager(port=PORT) # one TCP channel per robot
sim = Simulator(world, state, network, plugins)
network.start(network_handler) # background thread answers read-only queries at wire speed
physics, fpv = PhysicsSystem(), FPVCameraSystem()
# --- one iteration == one render tick ---
while not window_should_close():
world.update() # 1. flush last tick's entity adds / removes
script_msgs = script.at(clock.tick_count) # 2. I/O: this tick's script lines (if --script was passed).
replies = io_handler(sim, script_msgs) # keyboard + script + inbound TCP all become Messages and
# go through ONE core/plugin split; plugin messages reach
# plugins.io_handler (once), which turns them into Events
# that write the ECS. Returns (Message, Response) pairs.
fpv(world) # 3. render each robot's offscreen FPV camera
plugins.on_before_physics(world) # 4. physics: several subticks for a stable step, each one
for _ in clock.subticks(): # integrate -> detect -> resolve -> commit,
physics(world, dt) # vectorized over the component pools
plugins.on_after_physics(world)
sim.render_fpv_cameras() # 5. render the scene, then send exactly one reply
sim.render_main_camera() # per message that arrived this tick
responses_handler(sim, replies, script_responses) # the ONLY place that touches a channel
io_handler and responses_handler bracket the tick: the first turns inbound messages into events and applies them to the World, the last guarantees every message that arrived gets exactly one reply by tick end. See Plugins for the detailed message-routing diagram (TCP channel → plugin → event → physics → reply).
Handlers return replies; protocol.py sends them
Every handler — core and plugin — returns (Message, Response) pairs. Nothing below protocol.py knows a channel exists, and protocol.responses_handler is the single place that decides where a reply goes:
| source | how it arrives | where its reply goes |
|---|---|---|
| socket client | ConnectionManager.get_one_message() |
client.channel.main2tcp (its thread is parked in put_then_get) |
script line (--script) |
extra_messages, keyed by tick |
appended to the list main() returns |
| keyboard | _keyboard_handler, polled each tick |
logged (error / debug) |
All three are real Messages with a client, so they share one validation path, one core/plugin split, and one reply route — the discriminator is client identity (msg.client is SCRIPT_CLIENT), not the message content. PluginsManager holds no Channel and imports no netcode transport.
Within one tick, core replies come before plugin replies. A core command is answered inside io_handler — it has to be, a socket client's thread is parked in put_then_get and cannot wait for the end of the tick — while a plugin command is answered by responses_handler after physics has run. So a tick containing move then sim_reset returns the sim_reset reply first. It is deterministic, just not submission order; the script fixtures rely on this and keep one kind of command per tick.
The script path is what the DST suite drives: test/robosim/integration/ runs main() in-process with a --script fixture and asserts the returned replies, so most behaviour is tested with no socket at all (see the README).
Module import hierarchy
Each module's docstring states its level. When adding new modules, pick the lowest level that satisfies your imports. Modules are organized in levels (1-4) like the TCP/IP stack: higher levels import from lower, never the reverse. (The ECS systems — PhysicsSystem, FPVCameraSystem — live in src/robolib/systems.py (level 3); world_to_dict/world_from_dict live in src/robosim/simulator_singleton.py, next to the Simulator, one layer above the engine lib.)
| Level | Modules | Description |
|---|---|---|
| 1 | constants, utils, traits, components | Pure primitives, no robosim imports. utils/ = logging, mathutils, clock, type aliases. traits = method-only ABCs (Drawable, Serializable, Restorable). components = the 6 data-only ECS components (HasTag, HasModel, HasPose, HasFPV, HasMotion, HasCollision) |
| 2 | netcode, physics, camera | Basic infrastructure. physics/ = stateless math (motion integrators, spatial hash-grid collision); netcode = msgpack codec + length-prefixed framing (send_packet/recv_packet) + RobosimClient (staged/connected); camera = raylib camera wrapper |
| 3 | entities, plugin, systems | entities = the generic add_entity spawn + make_nonserializable_data materializer (a "kind" is a component list + data recipe, not a class; per-kind helpers like _make_cube/build_robots live in the process); plugin = Plugin base + Event/Message/Response; systems = the ECS systems (PhysicsSystem, FPVCameraSystem), callables over the component pools |
| 4 | plugins_manager | PluginsManager: routes messages to plugins, orchestrates their events into ECS writes, and returns (Message, Response) pairs. Holds no Channel — sending is protocol.py's job |
Invariants
These tick-level contracts hold across the whole system; plugin authors in particular must respect them (see Plugins).
- Every message gets exactly one reply by tick end —
ok/errreply immediately;physicsreplies viaon_message_responseafter physics.PluginsManager.responses_handlerenforces it, and returns the pairs rather than sending them. protocol.pyis the single source of truth for channel traffic. It is the only module that reads from or writes to aChannel. Every other layer returnsResponses upward. A handler that needs to answer a client returns a reply; it never puts one on a queue.Response.kindpicks the path.ok/error→ reply as-is, skip physics.physics→ event → ECS write → physics →on_message_response. One taggedResponse, not a(payload, response)tuple — illegal states are unconstructable.- Address entities by id, not stored refs — entities are World rows, and a ref goes stale after
sim_load_state. Re-query each tick (world.query(HasFPV, HasCollision)). - Plugin order is priority — when two plugins target the same entity in one tick, exactly one event is applied: the last plugin in
--pluginsorder wins (--plugins manual_move trajectory_mission⇒ a running mission overrides manualmove). The losing message is still answered. - State that must survive
sim_get_statelives in components, not on the plugin. Plugins keep their ownto_dict/load_state_dict(e.g. mission via-points), but that state is not part of the World save — fine at 3 plugins, revisit if a plugin's state must hit the save file.