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

  1. Every message gets exactly one reply by tick end — ok/err reply immediately; physics replies via on_message_response after physics. PluginsManager.responses_handler enforces it, and returns the pairs rather than sending them.
  2. protocol.py is the single source of truth for channel traffic. It is the only module that reads from or writes to a Channel. Every other layer returns Responses upward. A handler that needs to answer a client returns a reply; it never puts one on a queue.
  3. Response.kind picks the path. ok/error → reply as-is, skip physics. physics → event → ECS write → physics → on_message_response. One tagged Response, not a (payload, response) tuple — illegal states are unconstructable.
  4. 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)).
  5. Plugin order is priority — when two plugins target the same entity in one tick, exactly one event is applied: the last plugin in --plugins order wins (--plugins manual_move trajectory_mission ⇒ a running mission overrides manual move). The losing message is still answered.
  6. State that must survive sim_get_state lives in components, not on the plugin. Plugins keep their own to_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.