3. Spawning and Driving a Crate over TCP (Protocol)

Example 1 made a scene object move from inside the server (a plugin, server-side Python). This one drives an entity through its whole life from outside, over the wire — no plugin, no server code. We spawn a crate, make it a mover, drive it, re-steer it in flight, freeze it, then delete it, using the five live-mutation commands: entity_spawn, entity_add_component, entity_set_data, entity_remove_component, entity_destroy. Every step is one payload piped into robosim-ncat.

Assumes a server is running on port 42069 (python cli/robosim.py).

1. Spawn a crate

entity_spawn takes the same (components, data) shape an entity has on disk — spawning is literally "load this one entity". Here: a crate model, a pose, and an AABB collider, parked at world position (0, 2, 2).

echo -e 'entity_spawn {"components": ["HasTag", "HasModel", "HasPose", "HasCollision"], "data": {"tag": "spawned", "model_path": "resources/models/cube/cube.obj", "texture_path": "resources/textures/crate.png", "scale": [1.0], "pose": [[1,0,0,0],[0,1,0,2],[0,0,1,2],[0,0,0,1]], "collider_kind": "AABB"}}' \
  | ./cli/robosim-ncat.py localhost -p 42069 --json

The response hands back the new entity's id — you need it for every step below:

{"status": "Added a new entity to the world", "entity_id": 16, "timestamp": "..."}

The id is assigned by the server (next free slot), so don't hard-code it. Capture it with jq:

EID=$(echo -e 'entity_spawn {"components": ["HasTag", "HasModel", "HasPose", "HasCollision"], "data": {"tag": "spawned", "model_path": "resources/models/cube/cube.obj", "texture_path": "resources/textures/crate.png", "scale": [1.0], "pose": [[1,0,0,0],[0,1,0,2],[0,0,1,2],[0,0,0,1]], "collider_kind": "AABB"}}' \
  | ./cli/robosim-ncat.py localhost -p 42069 --json | jq -r .entity_id)
echo "spawned crate is entity $EID"

2. Make it a mover — entity_add_component

The crate is static — it has HasModel/HasPose/HasCollision but no HasMotion. Add that component with empty data: every field falls back to its default, and the default physics_level is 2 (the acceleration+drag law). That bare add is the whole "mover" trait — from this tick on the crate is in the physics query:

echo -e 'entity_add_component {"entity_id": '"$EID"', "component": "HasMotion", "data": {}}' \
  | ./cli/robosim-ncat.py localhost -p 42069 --json
{"status": "Added component HasMotion to entity 16", "timestamp": "..."}

It still doesn't move on its own, though. motion_input is the only input surface, and it's a per-tick one: the main loop zeroes every mover's motion_input at the top of each frame (sim.world.query(HasMotion).motion_input = 0), and the laws convert whatever they find into velocity. A write survives one tick. So drive it with a background loop that writes the input at the control rate:

( while true; do
    echo -e 'entity_set_data {"entity_id": '"$EID"', "component": "HasMotion", "data": {"motion_input": [0.5, 0.0, 0.0, 0.0, 0.0, 0.0]}}' \
      | ./cli/robosim-ncat.py localhost -p 42069 --json > /dev/null
  done ) &
DRIVE_PID=$!
echo "driving crate $EID — stop it with: kill $DRIVE_PID"

Watch it accelerate along +x — the level-2 law integrates the input into velocity (v += dt·(clip(input)·max_acc − v·drag)), so the crate ramps toward its terminal velocity, and glides smoothly through any frame the loop misses (nothing snaps to zero):

for i in 1 2 3; do sleep 0.6; echo -e 'sim_get_state' \
  | ./cli/robosim-ncat.py localhost -p 42069 --json \
  | jq -c --argjson e "$EID" '.world.entities[]|select(.entity_id==$e)|{x:.data.pose[0][3], y:.data.pose[1][3], z:.data.pose[2][3]}'; done
# {"x":0.48,"y":2,"z":2}  ->  {"x":1.3,"y":2,"z":2}  ->  {"x":2.15,"y":2,"z":2}

(The exact numbers depend on how many writes a frame gets; the shape is what matters: +x advances ~0.8 m per 0.6 s once at cruise, y/z hold at 2.)

3. Re-steer it in flight — entity_set_data

entity_add_component only adds — call it again on HasMotion and it errors (the entity already has it). To change a field on a live entity you overwrite it with entity_set_data, and the write takes effect in that same frame. First kill the +x drive, then push +z instead:

kill $DRIVE_PID
( while true; do
    echo -e 'entity_set_data {"entity_id": '"$EID"', "component": "HasMotion", "data": {"motion_input": [0.0, 0.0, 0.5, 0.0, 0.0, 0.0]}}' \
      | ./cli/robosim-ncat.py localhost -p 42069 --json > /dev/null
  done ) &
DRIVE_PID=$!

Poll again: x and y settle (old velocity decays via drag) and z now climbs:

for i in 1 2 3; do sleep 0.6; echo -e 'sim_get_state' \
  | ./cli/robosim-ncat.py localhost -p 42069 --json \
  | jq -c --argjson e "$EID" '.world.entities[]|select(.entity_id==$e)|{x:.data.pose[0][3], y:.data.pose[1][3], z:.data.pose[2][3]}'; done
# {"x":2.7,"y":2,"z":2.1}  ->  {"x":2.78,"y":2,"z":2.9}  ->  {"x":2.78,"y":2,"z":3.7}

entity_set_data writes any field of a component the entity already has — a new motion_input to re-steer, physics_level to switch law, a new pose to teleport.

4. Freeze it — entity_remove_component

Stop the drive and drop HasMotion — the crate's entire motion data — and it stops. All physics steps then skip it (query(HasPose, HasMotion) no longer matches), and nothing advances its pose. Its HasCollision stays, so it's a solid, static crate again:

kill $DRIVE_PID
echo -e 'entity_remove_component {"entity_id": '"$EID"', "component": "HasMotion"}' \
  | ./cli/robosim-ncat.py localhost -p 42069 --json
{"status": "Component HasMotion removed from entity 16", "timestamp": "..."}

Its component set is now motion-free, and the pose stops changing no matter how often you poll:

echo -e 'sim_get_state' \
  | ./cli/robosim-ncat.py localhost -p 42069 --json \
  | jq -c --argjson e "$EID" '.world.entities[]|select(.entity_id==$e)|.components'
# ["HasModel","HasPose","HasTag","HasCollision"]   (no HasMotion)

5. Delete it — entity_destroy

Finally, remove the entity from the world entirely:

echo -e 'entity_destroy {"entity_id": '"$EID"'}' \
  | ./cli/robosim-ncat.py localhost -p 42069 --json
{"status": "Entity 16 destroyed", "timestamp": "..."}

It's gone — a lookup by its id in sim_get_state now returns nothing:

echo -e 'sim_get_state' \
  | ./cli/robosim-ncat.py localhost -p 42069 --json \
  | jq --argjson e "$EID" '[.world.entities[]|select(.entity_id==$e)]|length'
# 0

Destroying an id that isn't in the world is an error, not a silent no-op — same for removing a component the entity doesn't have. Bad mutations are rejected cleanly; they never crash the sim.

Why it works

motion_input is a per-tick input surface, not a setting. The physics tick is input → integrate → detect → resolve → commit. The main loop zeroes all movers' motion_input before each frame, so what you wrote last frame is gone; the laws read it each tick and write candidate_velocity/candidate_acceleration. Those candidates are per-tick staging owned by the physics system — recomputed, hence overwritten, every tick — so writing them from outside is not a handle (the old "set candidate_velocity once = perpetual motion" trick is dead: level 1 recomputes it from the input, level 2's drag decays the velocity to zero when the input stops). To move, write motion_input every tick, as the drive loop above does.

Presence = movable; law = data. entity_add_component with HasMotion is the whole "mover" trait — no recipe of components to remember. The law is its physics_level field: 1 = velocity cap (v = clip(input)·max_velocities — moves only while the input is written), 2 = acceleration + drag (v += dt·(clip(input)·max_acc − v·drag) — accelerates toward terminal = clip(input)·max_acc/drag and glides to a stop when input stops). physics_level defaults to 2. Switching law on a live mover is one write — entity_set_data … "data": {"physics_level": 1} — no rm + add churn. An unknown value (e.g. 3) matches no law and the mover idles.

candidate_* are live, not persisted. They are per-tick staging buffers: systems read and write them, but they are not part of to_dict() — you won't see them in sim_get_state, sim_save_state, or a saved map, and the committed velocity/pose are what stick around.

Add vs. set vs. remove. entity_add_component grants a component the entity lacks (and rejects a duplicate); entity_set_data overwrites fields on one it already has; entity_remove_component takes it away. Because physics is query-driven, motion is just a component — so removing HasMotion (step 4) is how you make a mover static.

Spawn ≡ disk-load. The (components, data) you sent in step 1 is byte-for-byte what that entity looks like in a saved map (sim_get_state / sim_save_state). There is one entity-construction path; the wire and the disk feed the same one. So anything you can put in a map, you can spawn live.

See also