2. Adding Motion to a Scene Object (Plugin)

First, the big picture: the simulator is one loop

Before the example, hold one thing in your head. The whole simulator is a single loop, and every plugin lives inside it. Each iteration is one render tick (~60 Hz), and on every iteration the loop calls the same handful of callbacks on every plugin you loaded, in order. Your plugin's on_before_physics is just one of those calls — it fires once per tick, right before the physics step it feeds:

plugins = PluginsManager([ManualMovePlugin(), ..., CustomPlugin()])   # your --plugins selection

while not window_should_close():          # one iteration == one render tick (~60 Hz)
    world.update()                        # flush last tick's add/remove_component

    plugins.io_handler(world)             # inbound plugin TCP messages -> events written to the ECS

    plugins.on_before_physics(world)      # calls on_before_physics on EACH plugin, so CustomPlugin's
    for _ in clock.subticks():            #   runs HERE, this tick: read key B, (re)write motion_input
        physics(world, dt)                #   integrate -> detect -> resolve -> commit  (per subtick)
    plugins.on_after_physics(world)

    render()
    plugins.responses_handler(world)      # exactly one reply per message that arrived this tick

The consequence is the whole trick. Your hook runs again next tick, and the tick after that. So anything you write inside it, you re-assert every tick. The physics is step-driven: the main loop zeroes every mover's motion_input at the top of each tick and the physics laws turn whatever the input was into velocity — write the input once and the thing nudges for a frame; write it every tick and the thing keeps moving. Write a different value next tick and you've steered it — ramp it, turn it, stop it. That's not a limitation to work around; it is the handle for live control. Every hook is per-tick like this (on_before_physics, on_after_physics, io_handler, responses_handler) — the full loop is on the Architecture & Main Loop page.

That's the mental model. Now the example.

Making the house move

Scene objects — cubes, the house — are static: they carry HasPose/HasModel but no HasMotion. But motion is not a robot-only thing. The physics system moves any entity with HasPose + HasMotion; a robot is just an entity that also drives its own input. Adding HasMotion is the entire "mover" trait — presence is movable, and the law it follows is a field on that same component (physics_level: 1 = velocity-cap law, 2 = acceleration+drag law).

So to make the house move you give it HasMotion and write its input surface every tick (that's the loop above). A plugin does both at runtime:

import numpy as np
import raylib as rl
from robolib.plugin import Plugin
from robolib.components import HasMotion, HasCollision, ColliderKinds, PhysicsLevel

class CustomPlugin(Plugin):

    def on_before_physics(self, world):
        house_eid = 13            # 01-basic.json: the house = [HasModel, HasPose, HasTag] (static)
        ent = world.get_entity(house_eid)

        if rl.IsKeyPressed(rl.KEY_B):                    # press "B": toggle the "mover" capability
            if not ent.has_component(HasMotion):
                # presence = movable; physics_level picks the law (1 = velocity cap). The level-1
                # parameters live on the same component, so no second component to add.
                ent.add_component(HasMotion,
                                  physics_level=PhysicsLevel.LEVEL_1,
                                  max_velocities=np.float32((2.0, 2.0, 2.0, 1.0, 1.0, 1.0)))
                if not ent.has_component(HasCollision):  # make it collidable too, so it bounces
                    ent.add_component(HasCollision, collider_kind=np.array([ColliderKinds.AABB], "int32"))
                    ent.set_data(candidate_pose=ent.pose)  # collider is built from the *candidate* pose
            else:
                ent.remove_component(HasMotion)          # "static" = simply lacks HasMotion

        if ent.has_component(HasMotion):
            # the input surface: write it EVERY tick. on_before_physics runs right before the
            # physics step, after the main loop reset motion_input to 0. Level-1 law:
            # velocity = clip(input) * max_velocities
            ent.motion_input[:] = np.float32([0.5, 0.0, 0.0, 0.0, 0.0, 0.0])

        # add/remove_component are buffered; world.update() commits them so this tick sees the new set
        world.update()

Press B: the house gains HasMotion (and a collider) and drifts along +x at 1 m/s — level-1 law: velocity = clip([0.5, 0, 0, …]) · max_velocities = (1, 0, 0, 0, 0, 0) m/s, so it advances ~1 m per second and bounces off whatever it hits. Press B again: HasMotion comes off and it goes static — instantly, because the physics pipeline only moves entities in query(HasPose, HasMotion).

The details that make it work

1. motion_input, not candidate_velocity. The physics tick is input → integrate → detect → resolve → commit. The laws (motion_level1/motion_level2 in src/robolib/physics/motion.py) read motion_input and write candidate_velocity (+ candidate_acceleration for level 2); integrate then turns the candidate velocity into candidate_pose, and commit writes velocity/pose. The candidates are per-tick staging owned by the physics system — recomputed, hence overwritten, every tick. Writing candidate_velocity from a plugin (the old one-shot trick) no longer sticks; the input surface is motion_input.

2. Presence = movable; law = data. has_component / add_component / remove_component grant and revoke the mover capability live; there is no Frozen flag — "static" simply means "lacks HasMotion". The law is the physics_level field on that component: 1 is the velocity-cap law (v = clip(input)·max_velocities, motion while commanded) and 2 is the acceleration+drag law (v += dt·(clip(input)·max_acc − v·drag), ramps toward a terminal velocity and glides to a stop when input stops). Switching law is one write — ent.set_data(physics_level=PhysicsLevel.LEVEL_2) — not rm + add churn. physics_level defaults to 2, so a bare add_component(HasMotion) already moves under the default law.

3. Colliders follow the candidate pose. _detect_collisions builds collider_bbox from candidate_pose + the model bbox — the future pose, not the committed one. A static entity's candidate is seeded at spawn (make_nonserializable_data in src/robolib/entities.py: candidate_pose = pose), so it's already correct here; the one-liner above is belt-and-braces for entities you teleport later (pose moves, candidate_pose must follow or the collider stays behind).

4. Structural changes are lazy. add_component / remove_component are buffered. Call world.update() before code in the same tick reads the new component set (here: before physics and the motion_input write need it committed).

Registering it

A plugin runs only if it is registered (mapped to a name) and selected (named at startup):

  1. Register — drop your module in src/plugins/ (say custom_plugin.py) and add the class to REGISTERED_PLUGINS in src/plugins/__init__.py. That package is the only place you touch — the server (robosim.py) never changes:

    # src/plugins/__init__.py
    from .custom_plugin import CustomPlugin       # your module under src/plugins/
    
    REGISTERED_PLUGINS = {
        "manual_move": ManualMovePlugin,   # the built-ins
        "custom": CustomPlugin,            # your plugin, under a short name
    }
    
  2. Select — start the server with that name: python cli/robosim.py --plugins custom. The flag takes several (--plugins manual_move custom); the server hands REGISTERED_PLUGINS to PluginsManager, which instantiates REGISTERED_PLUGINS[name]() for each, and every plugin's endpoints join the protocol. With no flag it defaults to manual_move.

See also