Controlling robots: policies and IK¶
RobotControllerComponent is where robot control lives. It hosts two control surfaces in
one component: a list of policy slots that bind trained policies to the robot, and a
single motion graph that drives IK targets. Most real scenes combine both: a policy
drives the lower body, IK drives an arm, and a joint-ownership mask keeps them out of
each other's way.
The worked example throughout this page is a humanoid carrying several policies and an arm IK chain: a walker policy for gait, a rotator policy for clean turn-in-place, and motion-graph IK for arm reach and grasp. The IK examples use the motion graph of the bundled Franka Emika Panda arm, which ships with the editor.
In short
Activate a policy slot with SetPolicyActive and send it commands with SetFloat
every tick while it runs. Restrict each policy to its joints with SetDrivenJoints.
Set motion-graph inputs by name with SetInputVector3, SetInputFloat and
SetInputTrigger, using Identifier keys cached in static fields. Input names are
defined by each graph.
Activating and driving a policy¶
Two calls drive every policy: SetPolicyActive and SetFloat (or SetBool for boolean
commands). Both take a slot ID and, for commands, a command ID:
- Slot IDs are the Id values under Policy Slots in the Robot Controller
Inspector. The scripting API has a built-in
PolicyIdsenum (Walker = 1,Rotator = 2), which fits robots whose walker and rotator slots use those IDs. On every manual scene save, the editor also writes a scene-specific<Scene>PolicyIdsenum toAssets/Scripts/Source/Generated, with one member per named slot, so you can use the IDs of your own scene. - Command IDs come from a command enum for the policy, such as the built-in
WalkerCommandsandRotatorCommands(SetVx = 1,SetVy = 2,SetYawRate = 3). Adding a policy to a robot also writes a<Policy>Commandsenum for that policy to the same folder.
using Hazel;
public class WalkerDriver : Entity
{
private RobotControllerComponent? m_Rcc;
private RobotController m_Robot; // typed wrapper, implicit from m_Rcc
protected override void OnCreate()
{
m_Rcc = GetComponent<RobotControllerComponent>();
if (m_Rcc is null)
return;
m_Robot = m_Rcc;
m_Robot.SetPolicyActive(PolicyIds.Walker, true);
}
protected override void OnUpdate(float ts)
{
if (m_Rcc is null)
return;
m_Robot.SetFloat(PolicyIds.Walker, WalkerCommands.SetVx, 0.5f); // m/s
m_Robot.SetFloat(PolicyIds.Walker, WalkerCommands.SetVy, 0.0f); // m/s
m_Robot.SetFloat(PolicyIds.Walker, WalkerCommands.SetYawRate, 0.0f); // rad/s
}
}
RobotController is a thin wrapper struct that adds enum-typed overloads of the
policy commands and converts implicitly from RobotControllerComponent, so caching
it alongside m_Rcc keeps every policy call site readable. The component still
exposes the raw uint form m_Rcc.SetFloat(slotId, commandId, value) for code
that does not use the enums, and it remains the entry point for motion-graph inputs
further down this page.
When commands take effect¶
A command reaches a policy only while its slot is running:
- A slot activated with
SetPolicyActive(slotId, true)is started at the next physics step. Commands sent before then, including in the same tick, are ignored without a message. - A deactivated slot forgets the commands it was sent. Send them again after reactivating it.
The simplest rule is the one the walker example follows: send the active policy's commands every tick. Two mistakes are reported in the console:
| Message | Cause |
|---|---|
RobotController.SetPolicyActive() - slot id N does not exist on entity. |
No slot on this robot has that Id |
RobotController.SetFloat() - slot N has no command id M declared. |
The policy has no command with that ID |
Give slots names that are valid C# identifiers
The generated <Scene>PolicyIds enum uses each slot's Name as a member name.
Start names with a letter and keep them to letters, digits and underscores, such as
walker, so the enum compiles.
Switching between policies¶
A robot can carry several trained policies and swap between them. The walker handles gait; the rotator is trained to turn cleanly in place from a standstill, which the walker is not. A turn-to-face routine has to bring the body to a halt with the walker, hand control to the rotator, wait until the heading is aligned, damp residual rotation, then hand control back.
This cannot be a single-frame swap. Deactivating a policy stops it from issuing new actions, but it does not stop the robot: the body keeps whatever velocity it has, and the next policy starts from that state with no knowledge of what came before. If the rotator takes over while the body still moves forward, it starts outside the standstill conditions it was trained for and stumbles. The walker has to decelerate the body across several ticks before the swap happens.
The handoff is therefore a small state machine spanning multiple ticks of the Robot runner (50 Hz by default, so each tick is 20 ms). Commands are sent at the end of every tick, for whichever policy the current phase uses:
using Hazel;
public class TurnToFace : Entity
{
private enum Phase { Walking, DecelBody, Rotating, DampRotation }
private const int k_DecelTicks = 6; // walker brings the body to a standstill
private const int k_DampTicks = 4; // rotator damps residual rotation
// Walking commands, set by a navigation script.
public float WalkVx = 0.0f; // m/s
public float WalkYawRate = 0.0f; // rad/s
private RobotControllerComponent? m_Rcc;
private RobotController m_Robot;
private Phase m_Phase = Phase.Walking;
private int m_TicksInPhase = 0;
private float m_DesiredYawRate = 0.0f;
protected override void OnCreate()
{
m_Rcc = GetComponent<RobotControllerComponent>();
if (m_Rcc is null)
return;
m_Robot = m_Rcc;
m_Robot.SetPolicyActive(PolicyIds.Walker, true);
}
// Called by a navigation script when a heading change is needed.
public void RequestTurn(float yawRate)
{
m_DesiredYawRate = yawRate;
SetPhase(Phase.DecelBody);
}
protected override void OnUpdate(float ts)
{
if (m_Rcc is null)
return;
m_TicksInPhase++;
switch (m_Phase)
{
case Phase.DecelBody:
if (m_TicksInPhase >= k_DecelTicks)
{
m_Robot.SetPolicyActive(PolicyIds.Walker, false);
m_Robot.SetPolicyActive(PolicyIds.Rotator, true);
SetPhase(Phase.Rotating);
}
break;
case Phase.Rotating:
if (HeadingAligned())
SetPhase(Phase.DampRotation);
break;
case Phase.DampRotation:
if (m_TicksInPhase >= k_DampTicks)
{
m_Robot.SetPolicyActive(PolicyIds.Rotator, false);
m_Robot.SetPolicyActive(PolicyIds.Walker, true);
SetPhase(Phase.Walking);
}
break;
}
SendCommands();
}
// Resend the active policy's commands every tick. A policy ignores commands sent in
// the tick it is activated and forgets them when deactivated, so the policy that
// has just taken over picks these up from the next tick on.
private void SendCommands()
{
switch (m_Phase)
{
case Phase.Walking:
SendWalker(WalkVx, WalkYawRate);
break;
case Phase.DecelBody:
SendWalker(0.0f, 0.0f); // walker still active: brake to a standstill
break;
case Phase.Rotating:
SendRotator(m_DesiredYawRate);
break;
case Phase.DampRotation:
SendRotator(0.0f); // rotator still active: damp leftover spin
break;
}
}
private void SendWalker(float vx, float yawRate)
{
m_Robot.SetFloat(PolicyIds.Walker, WalkerCommands.SetVx, vx);
m_Robot.SetFloat(PolicyIds.Walker, WalkerCommands.SetVy, 0.0f);
m_Robot.SetFloat(PolicyIds.Walker, WalkerCommands.SetYawRate, yawRate);
}
private void SendRotator(float yawRate)
{
m_Robot.SetFloat(PolicyIds.Rotator, RotatorCommands.SetVx, 0.0f);
m_Robot.SetFloat(PolicyIds.Rotator, RotatorCommands.SetVy, 0.0f);
m_Robot.SetFloat(PolicyIds.Rotator, RotatorCommands.SetYawRate, yawRate);
}
private void SetPhase(Phase phase)
{
m_Phase = phase;
m_TicksInPhase = 0;
}
private bool HeadingAligned() => /* compare current heading to target */ true;
}
Two things to notice. First, the zero-velocity commands of the DecelBody phase go to
the walker while it is still active, so the walker spends k_DecelTicks ticks
decelerating the body before it is deactivated. Sending those commands in the same tick
as SetPolicyActive(walker, false) would achieve nothing: the walker stops on the next
physics step and never acts on them. Second, the same shape applies in reverse: the
rotator is commanded to zero yaw rate, runs for k_DampTicks more ticks to damp any
leftover rotation, and only then hands the body back to the walker. Tick counts are
robot-specific and worth tuning against observed deceleration on the target platform.
Joint ownership¶
When two control surfaces want different joints, declare which joints each is allowed
to write. SetDrivenJoints takes an array of joint names per policy slot. A non-empty
array is an explicit whitelist; an empty array means every joint in the policy's
descriptor. Names must match the robot's joint names exactly, as the slot's Driven
Joints checklist in the Inspector lists them. Names the policy does not drive are
ignored.
Rather than typing names out, you can collect them from the robot model, matching the same name patterns the Inspector's Legs and Waist presets use:
using System.Collections.Generic;
using Hazel;
public class ArmReachMask : Entity
{
private RobotControllerComponent? m_Rcc;
private RobotController m_Robot;
private string[]? m_LegsAndWaist;
protected override void OnCreate()
{
m_Rcc = GetComponent<RobotControllerComponent>();
if (m_Rcc is not null)
m_Robot = m_Rcc;
}
// Restrict the walker to legs and waist while an IK chain drives an arm.
public void HandArmToIK()
{
if (m_Rcc is null)
return;
m_LegsAndWaist ??= JointsMatching("_hip_", "_knee_", "_ankle_", "waist_");
if (m_LegsAndWaist.Length > 0) // an empty array would mean "every joint"
m_Robot.SetDrivenJoints(PolicyIds.Walker, m_LegsAndWaist);
}
// Give the walker the whole body back.
public void HandArmToWalker()
{
if (m_Rcc is not null)
m_Robot.SetDrivenJoints(PolicyIds.Walker, new string[0]);
}
// The names of the robot's joints that contain any of the given patterns.
private string[] JointsMatching(params string[] patterns)
{
var names = new List<string>();
var scene = GetComponent<MujocoSceneComponent>();
if (scene is null)
return names.ToArray();
for (uint i = 0; i < scene.ModelNjnt; i++)
{
string name = scene.GetJointNameByIndex(i);
foreach (string pattern in patterns)
{
if (name.Contains(pattern))
{
names.Add(name);
break;
}
}
}
return names.ToArray();
}
}
A walking-and-reaching scene switches the mask each phase: full-body walker when no arm is extended, legs-and-waist when one arm is on IK, legs-and-one-arm when one arm carries something and the other is reaching for something new. The mask is what keeps the policy from fighting the IK over shared joints. The motion graph only drives joints that no active policy has claimed.
Driving IK targets¶
The motion graph runs every step on a robot that has one attached. Scripts talk to it
by writing the named inputs of its motion graph (.hpodgraph), addressed through
cached Identifier keys.
The bundled Panda's IK graph, MG_Panda_Pod_IK, takes four inputs: TargetPosition,
TargetOrientation, Duration and Start. A pulse on Start begins a new move from
the arm's current pose, towards the most recent target, over Duration seconds:
using Hazel;
public class ArmReach : Entity
{
private static readonly Identifier s_TargetPosition = new Identifier("TargetPosition");
private static readonly Identifier s_TargetOrientation = new Identifier("TargetOrientation");
private static readonly Identifier s_Duration = new Identifier("Duration");
private static readonly Identifier s_Start = new Identifier("Start");
private RobotControllerComponent? m_Rcc;
protected override void OnCreate()
{
m_Rcc = GetComponent<RobotControllerComponent>();
}
// Move the hand to a world-space pose over the given number of seconds.
public void MoveTo(Vector3 worldPosition, Vector3 eulerRadians, float seconds)
{
if (m_Rcc is null)
return;
m_Rcc.SetInputVector3(s_TargetPosition, worldPosition);
m_Rcc.SetInputVector3(s_TargetOrientation, eulerRadians * Mathf.Rad2Deg);
m_Rcc.SetInputFloat(s_Duration, seconds);
m_Rcc.SetInputTrigger(s_Start); // pulse: start the move
}
}
The Panda graph takes positions in metres in world space, and orientations as Euler angles
in degrees, so the script applies Mathf.Rad2Deg at the boundary. Cache the
Identifier instances in static readonly fields; they are not free to construct every
frame.
Input names, and the units they expect, are defined by each graph. The two-arm Panda
graph, MG_TwoPandas, takes TargetPositionLeft, TargetPositionRight,
TargetOrientationLeft and TargetOrientationRight. Other graphs use other names and
can expose further inputs. To see a graph's inputs, look below the Motion Graph field of the
Robot Controller in the Inspector, or open the graph in the graph editor and check its
Parameters tab. See Procedural Graphs.
A few rules apply to every input:
- A name the graph does not have is ignored without a message, so copy names exactly.
- A value set from a script stays in effect until you set it again, and takes precedence over the value set for the robot in the Inspector.
SetInputTriggersets a checkbox input for a single update, the same as Send pulse in the Inspector.GetInputBool,GetInputInt,GetInputFloatandGetInputVector3read an input's current value.SetMotionGraphActive(false)stops the motion graph from driving the robot until you turn it back on.IsMotionGraphActive()returnstruewhen a graph is assigned and turned on.
The motion graph leaves alone any actuator a script controls directly. The Panda's
gripper is driven this way: the script sets IK = false on the gripper's
MujocoActuator and writes its Value.
The combined pattern: walk and reach¶
A walk-and-pickup cycle on a humanoid is the canonical walker + IK combination, structured as a sequence of phases:
flowchart LR
A[Walk to cup<br/>walker, full body] --> B[Settle<br/>walker, zero commands]
B --> C[Policy → IK<br/>mask walker to legs+waist,<br/>park IK target near cup]
C --> D[Reach<br/>IK slides target onto cup]
D --> E[Close<br/>gripper close, hold]
E --> F[IK → Policy<br/>blend back to walker]
The state the controller carries through this is small: the active arm (left or right), the target's world position, the current IK target, and a phase flag. The walker keeps the body upright through every phase; the IK only takes over the active arm; the joint mask keeps them sharing the body without fighting.
Where to go next¶
- The editor-side view in Robots → Adding policies and motion graphs covers the Add Policy context menu and what the Robot Controller Inspector shows.
- Working with components → MuJoCo bodies has the script-level surface for the MuJoCo components.
- Getting started has the minimal WASD walker driver as a stripped-down version of the policy patterns above.
- The
RobotControllerComponentandRobotControllerAPI reference list every member.