Plugins

Plugins add behavior to the World: manual move, trajectory missions, race. They turn TCP commands into events (which become ECS writes → physics) and may draw overlays. There is one base class — Plugin (src/robolib/plugin.py, level 3) — and one orchestrator — PluginsManager (level 4). Concrete plugins live in the plugins package (src/plugins/, app-level), registered by name in that package's __init__.py and selected with --plugins:

# src/plugins/__init__.py — the single registration point
from .manual_move_plugin import ManualMovePlugin
from .trajectory_mission_plugin import TrajectoryMissionPlugin
from .race_plugin import RacePlugin

REGISTERED_PLUGINS = {
    "manual_move":        ManualMovePlugin,        # default
    "trajectory_mission": TrajectoryMissionPlugin,
    "race":               RacePlugin,
}

The server imports this map (from plugins import REGISTERED_PLUGINS) and hands it to PluginsManager at construction — so adding a plugin touches only the plugins package, never robosim.py. Then select at startup:

./cli/robosim.py --plugins manual_move race   # no flag → defaults to manual_move

A plugin declares its endpoints (the TCP commands it owns) and answers each with a Response:

class Plugin(Drawable, Restorable, ABC):
    @property
    def endpoints(self) -> list[Endpoint]: ...                 # the endpoints (TCP commands) routed here

    # Required: one message → one Response (ok / err / physics).
    def on_message_receive(self, world, entity_id, message) -> Response: ...

    # Required: final reply for a `physics` Response, after physics ran (so it can read statuses).
    def on_message_response(self, world, event) -> Response: ...

    # Optional: internal events with no message (wind, a running mission's motion). These get no reply.
    def on_tick(self, world) -> list[Event]: ...

    # Optional: hooks around the physics system.
    def on_before_physics(self, world): ...
    def on_after_physics(self, world): ...

    # Optional: draw overlays. Called once for the main camera, once per FPV robot (entity_id set).
    def draw(self, world, entity_id: int | None = None): ...

Response has three constructors, one per kind — the kind picks the path through the tick:

Response.ok("move_applied", new_state=...)   # success → wire reply as-is, skips physics
Response.err("control_input not provided")   # error   → wire reply as-is, skips physics
Response.physics(motion_input=ctrl_input)    # → Event → write ECS → physics → on_message_response

Message flow within a tick

The PluginsManager routes every inbound TCP message to the plugin(s) that own its command, gathers the resulting events, runs physics, then sends exactly one reply per message. 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.

                              ┌──────────────────────────────────────────────────┐
                              │                PLUGINS MANAGER                   │
        TCP channels          │                                                  │
       ┌──────────┐           │   ┌─────────┐   ┌─────────┐   ┌─────────┐        │
       │ Robot 0  │───msg────►│   │ Manual  │   │ Mission │   │  Race   │  ...   │
       │ channel  │           │   │  Move   │   │ Plugin  │   │ Plugin  │        │
       └──────────┘           │   └────┬────┘   └────┬────┘   └────┬────┘        │
       ┌──────────┐           │        │  on_message_receive / on_tick           │
       │ Robot 1  │───msg────►│        └────────────┬──────────────┘  → Events   │
       │ channel  │           │                     ▼                            │
       └──────────┘           │   ┌──────────────────────────────────────────┐   │
              ▲               │   │ io_handler: Events → write ECS data       │   │
              │               │   │ (plugin order = priority: mission beats   │   │
              │               │   │  manual move for the same robot)          │   │
              │               │   └─────────────────┬────────────────────────┘   │
              │               │                     ▼                            │
              │               │   ┌──────────────────────────────────────────┐   │
              │               │   │ PhysicsSystem (× subticks) over the World:│   │
              │               │   │ integrate → detect → resolve → commit     │   │
              │               │   └─────────────────┬────────────────────────┘   │
              │               │                     ▼                            │
              │               │   ┌──────────────────────────────────────────┐   │
              │               │   │ responses_handler: on_message_response →  │   │
              │               │   │ one reply per message (reads statuses)    │   │
              │               │   └─────────────────┬────────────────────────┘   │
              │               └─────────────────────┼────────────────────────────┘
              │                                     │
              └───────────────response──────────────┘

While a mission is running

The TrajectoryMissionPlugin is stateful, and a running mission changes how a few commands behave. This is plugin logic, not part of any endpoint's schema — so it lives here, not in the Protocol Endpoints reference:

  • sim_load_state / sim_reset are refused (the server returns an error): you can't swap the world out from under a live mission.
  • move on robot 0 is overridden by the mission's own motion; the reply is move_not_applied (plugin order = priority — the mission beats manual move for the same robot).
  • mission_start / mission_add_via_point / mission_clear_via_points are not hard-guarded: add/clear silently reset the mission phase mid-run, and start restarts rather than refusing. Treat the mission as exclusive. (Tracked by task 97 / the ECS mission port #137.)

Everything else — and the whole race plugin — is unaffected.

Before writing a plugin, read the tick invariants on the Architecture & Main Loop page — they spell out the guarantees this flow relies on (one reply per message, Response.kind picks the path, address entities by id, plugin order = priority, durable state lives in components).