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_resetare refused (the server returns an error): you can't swap the world out from under a live mission.moveon robot 0 is overridden by the mission's own motion; the reply ismove_not_applied(plugin order = priority — the mission beats manual move for the same robot).mission_start/mission_add_via_point/mission_clear_via_pointsare not hard-guarded:add/clearsilently reset the mission phase mid-run, andstartrestarts 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).