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 message to the plugin(s) that own its command, gathers the resulting events, runs physics, then produces 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.
The manager never sends a reply — it returns one. Both handlers hand back (Message, Response) pairs, and protocol.py is the single source of truth for channel traffic: it owns the only reads from and writes to a Channel. PluginsManager holds no channel and imports no netcode transport, which is also why it can be driven from a script or a test with no socket in sight.
A message can reach the manager from three sources — a socket client, a --script line at tick N, or the keyboard — and all three are real Messages with a client, so the manager cannot tell them apart and does not try. protocol.responses_handler routes each reply by client identity: socket → channel.main2tcp, script → the list main() returns, keyboard → the log.
Three message sources, one path. protocol.py is the only module that touches a Channel.
socket client ─┐
script line ─┼──► protocol.io_handler: drain + ONE core/plugin split
keyboard ─┘ │
├── core cmd ──► _handle_message
│ └─► Response
│
└── plugin cmd ──► PluginsManager.io_handler
│
on_message_receive / on_tick
│
├─► Events ──► write ECS
│ (last plugin in
│ --plugins order wins
│ per entity)
└─► Response (rejected msgs)
PhysicsSystem (x subticks): integrate -> detect -> resolve -> commit
PluginsManager.responses_handler
└─► Response (on_message_response)
Every Response above flows back to protocol.responses_handler, which routes by client identity:
socket ──► client.channel.main2tcp (its thread is parked in put_then_get)
script ──► the list main() returns
keyboard ──► the log
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).