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(fast_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
keyboard_handler(sim) # 2. I/O: keyboard, then global cmds (sim_reset / sim_load_state),
global_handler(sim) # then inbound TCP messages — plugins turn them into
plugins.io_handler(world) # Events that write the ECS
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
plugins.responses_handler(world)
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).
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 two ECS systems — PhysicsSystem, FPVCameraSystem — and world_to_dict/world_from_dict live one layer up, in server/server.py, next to the Simulator.)
| 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 13 data-only ECS components (HasPose, HasModel, HasVelocity6DoF, HasCollision, …) |
| 2 | network, physics, camera | Basic infrastructure. physics/ = stateless math (motion integrators, spatial hash-grid collision); network/ = channels + connection manager; camera = raylib camera wrapper |
| 3 | entities, plugin | entities = make_cube/make_mesh/make_robot spawn functions (a "kind" is a spawn fn + a _type recipe, not a class); plugin = Plugin base + Event/Message/Response |
| 4 | plugins_manager | PluginsManager: routes TCP messages to plugins, orchestrates their events into ECS writes, and sends replies |
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. 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, one wins (a running mission overrides manual
move). - 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.