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

  1. Every message gets exactly one reply by tick endok/err reply immediately; physics replies via on_message_response after physics. PluginsManager.responses_handler enforces it.
  2. 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.
  3. 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)).
  4. Plugin order is priority — when two plugins target the same entity in one tick, one wins (a running mission overrides manual move).
  5. 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.