Microduck
Personal course · pollen-robotics/microduck · microduck_rl

Microduck, From Zero

Microduck is a 25 cm, 800 g biped robot from Pollen Robotics. It walks, rolls on skates, kicks a ball and stands itself up, all with neural-network policies trained by reinforcement learning in simulation. This course teaches the background first, then both repositories line by line, then has you build things.

The whole project in one sentence. Teach a tiny robot to move by letting thousands of simulated copies practise in parallel, make the simulation wrong in many different ways so the lessons survive contact with the real servos, then run the result on the robot at 50 times a second behind a safety layer that has the final word.

0 · Start here

There are two repositories, and they split the work cleanly. Keep this split in your head and everything else has a place to go.

RepoLanguageJobRuns on
microduck_rlPythonTeach the duck skills: simulation, reinforcement learning (PPO), export to ONNX, publishA CUDA GPU, or Hugging Face Jobs
microduckRustThe duck's brain: a 50 Hz control loop, safety, radios, camera, signed updatesA Rockchip RK3566 board inside the robot
Training to robot pipelinemicroduck_rl (your GPU)microduck (the robot)PPO in MuJoCo Warp4096 ducks at onceexport.py → ONNXnormalizer baked inpublish → HF Hubpolicy.onnx + manifestrobotctl policy loadchecks 61 in, 14 outrobotd, 50 Hzsafety layer decides15 servosmouth not in policy

Facts to keep

  • About 25 cm tall and 800 g. Fifteen Dynamixel XL330 servos and an IMU (an orientation sensor) share one serial bus.
  • Policies control 14 joints: five per leg (hip yaw, hip roll, hip pitch, knee, ankle) and four for the neck and head. The fifteenth servo is the mouth, which the policy leaves out.
  • Everything runs at 50 Hz, so the robot decides every 20 ms, in training and on the robot.
  • Every policy reads the same 61 numbers and writes the same 14. That shared contract is what lets the robot swap from walking to standing up mid-fall.

Suggested schedule

DayDoTime
1Modules 0–1. Read the two L1 links marked "read now".3h
2Module 2 (skim the papers in the order given), then Module 3 with the microduck_rl repo open.3h
3Module 4 with the microduck repo open, then Projects 1 and 2 (no GPU needed).3h
4Projects 3–5 (simulator, your own client, a real training run). Modules 6–7.3h
5Quiz cold, the next morning. Re-read what you missed.40m

1 · Foundations

Four ideas carry the whole project: reinforcement learning, how a servo moves a joint, simulation, and the gap between simulation and reality.

1.1 Reinforcement learning in one picture

The RL loopPolicysmall neural net (ONNX)Robot or sim14 servos + IMUaction: 14 joint targetsobservation: 61 numbersreward(training only)every 20 ms (50 Hz)

Reinforcement learning (RL) trains a policy, a function from what the robot senses to what it does. Nobody tells it the right answer. It tries actions, gets a reward number that says how good things went, and slowly changes itself to collect more reward over time.

TermMeaningIn microduck
State / observationWhat the policy can see61 numbers: IMU, joint angles and speeds, recent actions, the command
ActionWhat it outputs14 joint position targets
RewardA score for one step, only used in training"Did you track the commanded speed? Stay upright? Move smoothly?"
EpisodeOne attempt, from reset to endA few seconds of walking, or one stand-up
ReturnThe sum of rewards over an episode, discountedWhat PPO tries to make larger
Policy networkThe neural net that maps observation to actionA small MLP, exported to ONNX

PPO (Proximal Policy Optimization) is the algorithm. In plain words: run the current policy in thousands of copies, see which actions turned out better than expected (the advantage), and nudge the policy toward them, but clip the nudge so one update can't change it too much. That clipping is why PPO is stable enough to be the default for legged robots.

PPO clipped policy update Old policy Action probability ratio New policy Clipped update 0.61 − 0.21.01 + 0.21.4 Raw nudge Clipped nudge Positive advantage

# the heart of PPO, in pseudocode
ratio     = pi_new(action | obs) / pi_old(action | obs)
objective = min(ratio * advantage,
                clip(ratio, 1 - 0.2, 1 + 0.2) * advantage)   # don't move more than ~20%
maximise the average objective over a big batch

Why thousands of copies: RL needs a huge amount of experience. microduck_rl runs --env.scene.num-envs 4096 ducks in one GPU simulation, so an hour of training is years of practice. This "massively parallel" style is what made learned locomotion practical (Rudin et al. 2021, in Module 2).

  1. L1, read now: OpenAI Spinning Up, "Introduction to RL", parts 1–3. spinningup.openai.com. About 45 minutes, and the clearest intro there is.
  2. L2: Sutton & Barto, Reinforcement Learning: An Introduction, 2nd ed., chapters 1, 3 and 13. Free at incompleteideas.net/book.
  3. L3: Schulman et al., "Proximal Policy Optimization Algorithms", 2017. arXiv 1707.06347. Schulman et al., "High-Dimensional Continuous Control Using Generalized Advantage Estimation", 2015. arXiv 1506.02438.

Check yourself

1. In RL, what is the 'return'?

2. What is the 'advantage' of an action in PPO?

3. When is the reward signal used?

1.2 How a joint actually moves

The policy does not output torques. It outputs a target angle for each joint, and the servo's own controller pushes toward it. That controller is a PD controller:

PD joint controller Policy Target angle PD controller Servo torque Joint angle Angle error × kp Velocity × kd torque = kp × error − kd × velocity

torque = kp * (target - angle)  -  kd * velocity
         └─ spring toward target ┘   └─ damper ─┘
  • kp (stiffness): high means it snaps to the target; low means it is soft and lets gravity pull it.
  • kd (damping): stops it overshooting and wobbling.
  • Tiny hobby servos like the XL330 are voltage controlled. They have friction that depends on load, and the battery voltage sags when they work hard. At this scale, the servo model is most of the difference between simulation and reality. That's why microduck_rl uses a detailed actuator model called BAM instead of an ideal PD.
  • Backlash is play in the gears. The motor turns a degree before the joint moves. microduck_rl trains "Backlash" variants with ±1° of play per joint.
    Backlash joint play Motor turns ±1° play Joint moves later Ideal joint Backlash variant

Kinematics is the geometry. Forward kinematics means joint angles in, positions of feet and head out. The kinematics crate in the robot repo does this, for example to know where the head's depth sensor is pointing.

Check yourself

1. In torque = kp·(target − angle) − kd·velocity, what does a higher kp do?

2. What is backlash in a servo?

3. What does forward kinematics compute?

1.3 Simulation

Training on a real 800 g robot would break it thousands of times. So training happens in MuJoCo, a physics engine designed for robots and contact. microduck_rl uses mjlab, which runs MuJoCo on the GPU (MuJoCo Warp) with the rsl_rl PPO trainer. The robot's shape comes from its CAD model in Onshape, exported to MuJoCo's XML format (MJCF) with onshape-to-robot.

A simulator is always wrong somewhere: friction, contact, motor response, mass distribution. A policy that exploits the simulator's mistakes falls over on the real robot. This is the sim2real gap.

1.4 Crossing the sim2real gap

Why domain randomization worksTrain on a cloud of worlds, not one worldmass, friction, battery voltage, delay, backlash ...one tuned simthe real robotrandomized training worlds

Three tools, all used in microduck_rl:

  1. Domain randomization. Don't train in one simulated world. Each of the 4096 copies gets slightly different battery voltage, voltage sag, command delay, friction and backlash. A policy that works across all of them treats the real robot as just one more variation.
  2. Better models where it matters. Model the servo down to its voltage control law (BAM), and model backlash with an extra passive hinge per joint, read through the play just as the real encoder reads it.
  3. Sensor realism. Add noise, encoder bias and IMU misalignment to the observations, so the policy never relies on perfect readings.

The trade-off. Randomization makes a policy a little worse in any one simulated world and much better in the real one. You'll measure exactly this in Project 2.

Check yourself

1. What is the sim2real gap?

2. Why randomize battery voltage in training?

3. Why add noise and bias to observations in training?

1.5 ONNX and deployment

ONNX is a file format for neural networks that any runtime can execute. Training happens in PyTorch on a big GPU; the robot runs the ONNX file on a small ARM board. One trap the repo calls out: observations are normalized during training, so the normalizer must be baked into the ONNX file. Run a raw checkpoint and the policy sees unscaled numbers and behaves randomly. That's why there is exactly one safe export path, scripts/export.py.

ONNX normalizer trap PyTorch checkpoint scripts/export.py ONNX fileNormalizer Policy Raw 61 numbers 14 targets ✓ Same scaling as training Raw 61 numbers Raw checkpoint Policy only Random behaviour

Check yourself

1. What is ONNX used for here?

2. Why does in-sim play hide the normalizer bug?

3. Which tool turns the robot's CAD model into MuJoCo's format?

Close the page and answer

  1. What does a microduck policy output, angles or torques? Who turns that into torque?
  2. Why train 4096 copies at once?
  3. Name three things microduck_rl randomizes.
  4. Why must the normalizer live inside the ONNX file?
Check your answers
  1. Target angles, 14 of them. The servo's PD controller turns the error into torque.
  2. RL needs enormous experience. Parallel simulation compresses years of practice into an hour.
  3. Battery voltage, voltage sag under load, command delay, friction, backlash, and sensor noise and bias.
  4. The policy was trained on normalized inputs. Without the same scaling at runtime, it sees numbers it never learned from.

2 · Paper ladder

There's no single "microduck paper". The project stands on a short stack of papers. Read them in this order. For each, I've said what to take from it, so you can skim the rest.

#PaperTake this from it
1Grandia et al., "Design and Control of a Bipedal Robotic Character", RSS 2024 (Disney Research's BDX droid). arXiv 2501.05204The closest ancestor. A small expressive biped, RL policies for walking and standing, separate policies blended at runtime, and motion that has to look like a character, not only work.
2Rudin, Hoeller, Reist, Hutter, "Learning to Walk in Minutes Using Massively Parallel Deep RL", CoRL 2021. arXiv 2109.11978Thousands of robots on one GPU, the game-inspired curriculum, and rsl_rl, the PPO trainer mjlab builds on.
3Tobin et al., "Domain Randomization for Transferring Deep Neural Networks from Simulation to the Real World", IROS 2017. arXiv 1703.06907; Peng et al., "Sim-to-Real Transfer of Robotic Control with Dynamics Randomization", ICRA 2018. arXiv 1710.06537Randomize the world, and reality becomes one more sample. Peng randomizes dynamics (mass, friction, delay), which is exactly what microduck does.
4Hwangbo et al., "Learning Agile and Dynamic Motor Skills for Legged Robots", Science Robotics 2019. arXiv 1901.08652The actuator is the gap. They learned an actuator network from real data. microduck uses a physics-based actuator model (BAM) for the same reason.
5Duclusaud et al., "Extended Friction Models for the Physics Simulation of Servo Actuators", 2024 (the BAM work from Rhoban). arXiv 2410.08650, code Rhoban/bamWhy a voltage-controlled servo with load-dependent friction behaves unlike an ideal motor, and how to fit that model on a test bench.
6Ng, Harada, Russell, "Policy Invariance under Reward Transformations", ICML 1999.Potential-based reward shaping: pay for progress (Φ(next) − Φ(now)) and you speed up learning without changing what the best policy is. microduck's upright_progress reward is exactly this.
7Schulman et al., PPO, 2017. arXiv 1707.06347The clipped objective from Module 1. Read section 3 only.
8Todorov, Erez, Tassa, "MuJoCo: A physics engine for model-based control", IROS 2012; Zakka et al., "MuJoCo Playground", 2025. arXiv 2502.08844What the simulator does well (contacts, speed) and the GPU version that makes 4096 ducks possible. mjlab is in the same family: mujocolab/mjlab.
9Krakovna et al., "Specification gaming: the flip side of AI ingenuity", DeepMind blog 2020. deepmind.googleA catalogue of agents doing what the reward said, not what was meant. microduck's AGENTS.md is a field diary of the same thing.

Background, not required: Antoine Pirrone's community project Open Duck Mini is an open, small BDX-style duck that is worth comparing with microduck.

How to read a robotics paper quickly. Abstract, then the figure showing the system, then the reward or training table, then the real-robot results and failure cases. Skip derivations on the first pass. For each paper, write one line: "the problem it solves for microduck is ___".

Check yourself

1. Which paper is the closest ancestor of microduck?

2. What did Hwangbo et al. 2019 show about legged robots?

3. Which paper's main idea is 'pay for progress, not for being in a state'?

3 · The training repo, microduck_rl

3.1 Commands you'll actually type

# needs a CUDA GPU and uv. Smoke test first: catches most config errors for cents
uv run train Mjlab-Velocity-Flat-MicroDuck --env.scene.num-envs 64 --agent.max_iterations 5

# real training: about 1-2 h for a usable gait at 4096 envs
uv run train Mjlab-Velocity-Flat-MicroDuck --env.scene.num-envs 4096

# no GPU? the same command on Hugging Face Jobs
uv run train Mjlab-Velocity-Flat-MicroDuck --env.scene.num-envs 4096 --hf-jobs

uv run play Mjlab-Velocity-Flat-MicroDuck --wandb-run-path <entity/project/run_id>   # watch it
uv run scripts/export.py Mjlab-Velocity-Flat-MicroDuck --wandb-run-path <...>       # ONNX
uv run scripts/infer_policy.py --walking output.onnx    # drive it with the keyboard, CPU MuJoCo

3.2 The tasks (skills)

Task idSkill
Mjlab-Velocity-{Flat,Rough}-MicroDuckThe main one: walk at a commanded speed, with head-pose commands
Mjlab-VelStand-…Walking and fall recovery in one policy
Mjlab-StandUp-…Stand up from face-down, face-up or sitting
Mjlab-SitStand-…Commanded sit and stand
Mjlab-GroundPick-…Crouch and touch the floor with the beak
Mjlab-BallKick-Flat-MicroDuckKick a 70 mm, 15 g ball. The actor can't see the ball.
Mjlab-Roulade-Flat-MicroDuckForward roll over the head, land on the feet
…-Rollers, Swizzle, RollerSlope, SpinRoller-skate skills with passive wheels under the feet

Each main task has a -Backlash- twin with ±1° of gear play per joint.

3.3 The contract that holds it together

The 61-dimension observation contractWhat every microduck policy sees, every tick48 · proprioceptionthe body sensing itself: IMU, joints, recent actions346twistvx vy vyawheadposebody pose13 · command block, zero-padded if unusedSame layout for walk, stand-up, kick, rollso any policy can take over at any moment

Every policy, whatever its skill, reads 61 numbers: 48 of proprioception, then a 13-number command block of twist (3), head pose (4) and body pose (6), always in that order. A task that doesn't use a slot fills it with zeros rather than deleting it. Output is always 14. Because of this, the runtime can hand the robot from the walking policy to the stand-up policy at any instant. The publish tool refuses any ONNX that isn't [1,61] → [1,14].

without the contract Each skill has its own input layout. Switching mid-fall needs a translation layer, and any mismatch means the stand-up policy reads walking data as garbage, at the worst possible moment.

with it Policies are interchangeable parts. Community members publish new gaits to the Hugging Face Hub and the robot loads them with one command, with no new daemon release.

Check yourself

1. How many command values sit at the end of the 61-number observation?

2. Which task trains a forward roll that lands back on the feet?

3. What does the -Backlash- twin of a task add?

3.4 Reward design: the hardest part, written down

The repo's AGENTS.md is a list of lessons that "were each learned the hard way". These are the ones that transfer to any RL project:

  • RL optimizes the letter of the reward. Every gap gets exploited: a "ballistic whip" instead of a roll, a "head-tripod" instead of standing.
  • Never pay for being in a bad state. The policy parks in the cheapest pose that qualifies and farms it. Pay for progress instead.
  • No jackpots. If reaching a goal pays every step afterwards, arriving early with violence is the optimum. Track a slowly moving target instead.
  • Introduce smoothness penalties after the skill exists. A tax on attempting, active while a hard skill is being learned, makes "do nothing" the winner.
  • Train the zero command explicitly. Random sampling almost never produces exactly "stand still", which is the most common state on a real robot.
  • Sign conventions bite. A negative weight on a penalty that is already negative turns it into a reward, and the policy farms the violation. Check: every penalty's episode reward in wandb must be ≤ 0.

Here is the real fix for the "parks in a bad pose" problem, from src/mjlab_microduck/tasks/mdp.py:

def upright_progress(env, asset_cfg=_DEFAULT_ASSET_CFG):
    """Potential-based upright shaping: Δcos(tilt) per step.
    Pays for PROGRESS toward upright, charges for progress toward fallen,
    and pays exactly ZERO for holding any pose, so no state can farm it."""
    asset = env.scene[asset_cfg.name]
    quat = asset.data.root_link_quat_w
    cos_tilt = torch.nan_to_num(1.0 - 2.0 * (quat[:, 1] ** 2 + quat[:, 2] ** 2), nan=1.0)
    if not hasattr(env, "_upright_potential_prev"):
        env._upright_potential_prev = cos_tilt.clone()
    fresh = env.episode_length_buf <= 1                    # no spurious delta after a reset
    env._upright_potential_prev[fresh] = cos_tilt[fresh]
    delta = cos_tilt - env._upright_potential_prev
    env._upright_potential_prev = cos_tilt.clone()
    return delta

Read it line by line. 1 − 2(qx² + qy²) is the cosine of the trunk's tilt from a quaternion: 1 upright, 0 lying flat. The reward is this step's value minus last step's. Rising pays, falling costs, holding still pays nothing.

Upright progress reward RisingHoldingFalling 0 → 10.5 → 0.51 → 0 + reward0 reward− reward reward = cos(now) − cos(previous)
A full recovery from lying flat earns about +1 in total, wherever it parks on the way. Project 1 measures why this matters.

Check yourself

1. A smoothness penalty is active from step 0 on a hard new skill. What tends to happen?

2. Why is 'reach the goal, then get paid every step' a jackpot?

3. Why must the zero command be trained explicitly?

3.5 Curricula and reading a run

  • Training stages change reward weights and command ranges over time. If a metric drops exactly at a stage boundary, the pacing is wrong: stretch the stage, don't rush it.
    Curriculum stage boundary Stage 1 Stage 2 Metric Stretch the stage ResumeCheckpoint + step counter Warm startWeights, reset counter inherited counterLast stage too soon
  • Reverse curriculum: start some episodes partway through a manoeuvre, even nearly finished. That's the fix for "learns the start, never the last mile".
  • Warm start is not resume. Loading another task's checkpoint also restores its step counter, which jumps every curriculum to its last stage. The repo has an environment flag for exactly this.

3.6 Publishing a policy

uv run publish --onnx output.onnx --repo <user>/microduck-polite-bow --kind episodic --duration-s 4.0
# then, on a robot
sudo robotctl policy add polite-bow <user>/microduck-polite-bow
robotctl robot do polite-bow

--kind episodic means it runs for a fixed time and returns itself to standing (kicks, a bow). perpetual means it runs until told otherwise (a gait, a held pose).

Policy publish kinds Published policy EpisodicKick / bow Fixed duration Return tostanding PerpetualGait / held pose Until told otherwise obs_len: 61action_len: 14
The manifest also records obs_len: 61 and action_len: 14, and the robot refuses a policy whose claims disagree with it.

Check yourself

1. A metric drops exactly at a curriculum stage boundary. What does the repo advise?

2. What is a reverse curriculum?

3. What does --kind perpetual mean when publishing?

4 · The robot repo, microduck

The duck brain: daemons and socketstransports: they own nothing and send the same callspaddgamepadbtdphone over BLErobotctlCLI over sshmediadcamera, WebRTCJSON-RPC 2.0 · one line per message · one unix socket per servicerobotd50 Hz loop · safety · policiesthe only one touching motorsconfigdwifi · name · pad bondingsurvives a dead robotdupdaterdverify · swap · roll backsurvives a dead robotd↓ 15 servos + IMU on one serial bus

4.1 Seven small programs, not one big one

DaemonOwnsWhy it is separate
robotdThe 50 Hz loop, the motor bus, policies, safety, robot.healthThe only thing allowed to command a motor
configdWifi, name, pairing PIN, gamepad bondingMust work when robotd is broken, which is exactly when you need to reconfigure
updaterdSigned releases: verify, install, swap, health-gate, roll backSame: the recovery path can't depend on the thing it recovers
btd, padd, mediadNothing of the robot. Transports for BLE, gamepad and WebRTC.Replaceable without touching robot behaviour
tofdThe head's 8×8 depth sensorSlow, flaky sensor bring-up must not live next to the motors

The four rules in docs/design/architecture.md are worth memorising, because they are good design for any system:

  1. The recovery path (configd, updaterd, btd) survives a dead robotd.
  2. robotd is the authority on safety. Clients send intents ("go this fast"); robotd decides what is executable.
    robotd safety authority No direct motor command Client Requested move robotdSafety decision Applied move Motor bus requested / applied / limited_by
  3. The control loop never waits on another service. Everything it reads from elsewhere is a last-value cache.
  4. Every piece of state has exactly one owner.

4.2 How programs talk: JSON-RPC over unix sockets

→ {"jsonrpc":"2.0","id":1,"method":"robot.move","params":{"vx":0.15,"vy":0,"vyaw":0}}
← {"jsonrpc":"2.0","id":1,"result":{ ... "requested":[0.15,0,0], "applied":[...], "limited_by":[...] }}

One JSON object per line, one socket per service.

JSON RPC socket message Client Unix socketFile permissions robotd Request jsonrpc: 2.0 id: 1 method: robot.move params: vx, vy, vyaw Reply id: 1 requested applied limited_by One JSON object per line
The design doc compares eight options (gRPC, D-Bus, HTTP, tarpc and more) and explains the choice. Unix sockets come with free authorization: file permissions decide who can connect, and the kernel tells the daemon the caller's user id, which is used both to log and to allow or refuse calls that change the robot. A typo can't expose firmware control to the network, because there is no network port to bind.

Look at the reply to robot.move: it reports what you asked for, what was applied, and what limited it. That's rule 2 made visible.

Check yourself

1. What does 'single writer per piece of state' mean?

2. Why does robotd's loop never make a blocking call to another service?

3. A JSON-RPC notification differs from a request because it…

4.3 The 50 Hz tick

Each tick, robotd reads all sixteen devices on the bus (15 servos and the IMU board) in one transaction, builds the 61-number observation, runs the ONNX policy, applies safety, and writes the joint targets back. Reading the full bus costs about 8 ms, 40% of the 20 ms budget.

robotd fifty hertz tick Bus read: ~8 ms Other work: ~12 ms budget 0820 ms40% bus read 16 devices 61 observations ONNX policy Safety 14 joint targets 15 servos + IMU Drop late tick Enabled Policy loaded Fresh sensors No limp-fall Drive
So the loop is designed so that a late tick is dropped rather than letting the schedule drift. The policy only drives when four conditions all hold: the robot is enabled, a policy is loaded, sensors arrived this tick, and no limp-fall sequence owns the robot.

Why "sensors arrived this tick" matters. If a read fails and the loop reuses old numbers, the policy is controlling a robot that no longer exists. Refusing to drive is safer than driving blind.

4.4 Updates that can't brick a robot

How an update reaches a robotCI buildsand signsverifysignaturemove currentsymlink, restarthealth gate:robot.healthkeep it ✓put the oldone backthe old release stays on disk,so rollback is one symlink movehealthynot

A release is a whole directory. updaterd checks its signature, points the current symlink at it, restarts the services, and asks robot.health. If the answer isn't healthy, it puts the old release back by itself, and a boot counter catches crash loops that slip past. Your config lives outside the release directory, so it survives both an update and a rollback.

4.5 The simulated duck

scripts/duck-sim runs the real daemons against a MuJoCo body instead of servos. Everything above the bus (the loop, the policy, safety, IPC, robotctl) is the code a robot runs, unchanged. What it can't tell you: anything in a driver.

Duck simulation hardware seam Real code, unchanged robotctl / IPC robotd loop Policy Safety MuJoCo body Servo bus Not modelledBLE · Camera · NPU
The servo bus, BLE radio, camera and NPU are absent, not modelled.

Check yourself

1. Roughly how much of each 20 ms tick does reading the full bus take?

2. What survives both an update and a rollback?

3. Which duck-sim mode runs the daemons under real systemd?

5 · Build things

Six projects, from "runs on any laptop" to "trains a real policy". Each has an answer or expected result you can check.

Project 1 · Watch a reward get farmed (20 min, any computer)

Save this as reward_farming.py and run python reward_farming.py. It scores three scripted behaviours under two reward designs.

import math

STEPS = 200   # 4 s at 50 Hz

def stand_up(t):      return max(0.0, 90.0 - 90.0 * t / 50)    # rises over 1 s, then stands
def park_halfway(t):  return max(45.0, 90.0 - 90.0 * t / 50)   # rises to 45 degrees and parks
def stay_down(t):     return 90.0                              # never tries

def gated(tilts):     # +1 every step the trunk is "on the way up"
    return sum(1.0 for a in tilts if 20.0 <= a <= 70.0)

def potential(tilts): # delta cos(tilt), like microduck's upright_progress
    cos = [math.cos(math.radians(a)) for a in tilts]
    return sum(b - a for a, b in zip(cos, cos[1:]))

print(f"{'behaviour':<14}{'gated':>8}{'potential':>11}")
for name, f in [("stand_up", stand_up), ("park_halfway", park_halfway), ("stay_down", stay_down)]:
    tilts = [f(t) for t in range(STEPS)]
    print(f"{name:<14}{gated(tilts):>8.0f}{potential(tilts):>11.3f}")

Task: before running it, predict which behaviour each reward ranks first.

Real output, and what it means
behaviour        gated  potential
stand_up            27      1.000
park_halfway       188      0.707
stay_down            0      0.000
Reward farming, measuredSame three behaviours, two reward designsgated: +1 per step while tilt is 20–70°potential: Δcos(tilt) per step27stand up188park halfway0stay down1.000stand up0.707park halfway0.000stay downthe exploit wins by 7×the real skill wins

Under the gated reward, parking halfway pays seven times more than standing up, so an RL agent would learn to park. That's the exact bug the repo describes ("farmed from sitting, lying flat, and a head-tripod lean across three velstand runs"). Under the potential reward, standing is best and holding any pose earns nothing extra. Extension: add a behaviour that rises and falls repeatedly. What does each reward say? (Gated pays it again on every pass through the band; potential totals zero for every full up-and-down cycle.)

Project 2 · Domain randomization by hand (30 min, any computer)

Tune a simple joint controller two ways, then test both on a "real" leg that is heavier and laggier than the model. Save as domain_randomization.py:

import random
DT = 0.02  # 50 Hz

def run(kp, kd, mass, friction, delay, steps=150):
    """Step from 0 to 1 rad. Returns mean absolute tracking error (lower is better)."""
    q, v, err = 0.0, 0.0, 0.0
    queue = [0.0] * (delay + 1)                 # commands arrive `delay` ticks late
    for _ in range(steps):
        queue.append(kp * (1.0 - q) - kd * v)
        torque = queue.pop(0)
        v += (torque - friction * v) / mass * DT
        q += v * DT
        if abs(q) > 10: return 10.0              # unstable: worst score
        err += abs(1.0 - q)
    return err / steps

GAINS = [(kp, kd) for kp in range(2, 61, 2) for kd in (0.2, 0.5, 1.0, 1.5, 2.0, 3.0)]
nominal = min(GAINS, key=lambda g: run(*g, mass=1.0, friction=0.3, delay=0))
random.seed(0)
worlds = [(random.uniform(0.6, 2.5), random.uniform(0.1, 0.8), random.randint(0, 3)) for _ in range(60)]
robust = min(GAINS, key=lambda g: sum(run(*g, *w) for w in worlds) / len(worlds))

real = dict(mass=2.2, friction=0.15, delay=3)
for name, g in [("nominal-tuned", nominal), ("randomized", robust)]:
    print(f"{name:<14} kp={g[0]:>2} kd={g[1]:<4} sim {run(*g, 1.0, 0.3, 0):.3f}  real {run(*g, **real):.3f}")
Real output, and what it means
nominal-tuned  kp=22 kd=3.0  sim 0.153  real 0.434
randomized     kp=12 kd=3.0  sim 0.162  real 0.360

The randomized tuning is slightly worse in the clean simulation (0.162 against 0.153) and clearly better on the real leg (0.360 against 0.434). It picked softer gains that tolerate delay. That's the trade from Module 1.4. Try: set delay=4 on the real leg, or widen the mass range, and watch the gap change.

Project 3 · Run the simulated duck (45 min, Linux, macOS or WSL2)

mkdir duck && cd duck
git clone https://github.com/pollen-robotics/microduck
git clone https://github.com/pollen-robotics/microduck_rl     # duck-sim looks for it beside microduck
cd microduck_rl && uv sync && cd ../microduck                 # needs Rust (rustup) and uv installed
scripts/duck-sim               # a MuJoCo window opens and the duck stands up
scripts/duck-sim status        # health, and whether it is standing
scripts/duck-sim drive 0.15 0  # walk forward for 8 s
scripts/duck-sim ctl health    # anything robotctl does
scripts/duck-sim monitor       # joints, IMU, sticks, live
scripts/duck-sim down

On Windows, use WSL2 (Ubuntu); the daemons target Linux. If something fails, scripts/duck-sim log and simlog show each half.

Project 4 · Talk to robotd yourself (30 min, needs Project 3)

With the simulated duck up, its robotd socket is ~/.cache/duck-sim/duck-a.sock. Write a JSON-RPC client in plain Python:

import json, os, socket

sock = socket.socket(socket.AF_UNIX)
sock.connect(os.path.expanduser("~/.cache/duck-sim/duck-a.sock"))
f = sock.makefile("rw")
next_id = 0

def call(method, params=None):
    global next_id
    next_id += 1
    f.write(json.dumps({"jsonrpc": "2.0", "id": next_id, "method": method, "params": params or {}}) + "\n")
    f.flush()
    while True:                                # skip notifications until our reply arrives
        msg = json.loads(f.readline())
        if msg.get("id") == next_id:
            return msg

print(call("robot.health"))
print(call("robot.move", {"vx": 0.15, "vy": 0.0, "vyaw": 0.0}))
print(call("robot.move", {"vx": 5.0,  "vy": 0.0, "vyaw": 0.0}))   # absurd speed: watch "limited_by"

Task: compare requested and applied in the two move replies. Then send {"vx": 0, "vy": 0, "vyaw": 0} to stop.

What you should see

The absurd request is clipped: applied is smaller than requested, and limited_by names the reason.

Move request clipping Normal request requested applied requestedappliedlimited_by Absurd request:vx 5.0 requested applied Safety limit requestedappliedlimited_by Exact limit varies
That's robotd being "authoritative on safety". The exact field layout can change between versions, so if your reply looks different, read MoveState in duck-ipc-proto/src/lib.rs, which is the source of truth. If a call is refused, read the error message: mutating calls are only allowed from permitted user ids, and on the sim that's you.

Project 5 · Train and deploy a real policy (2 h, CUDA GPU or Hugging Face Jobs)

  1. cd microduck_rl, then the smoke test from 3.1 (64 envs, 5 iterations). It must finish without NaNs.
  2. Train Mjlab-Velocity-Flat-MicroDuck at 4096 envs. Add --hf-jobs if you have no GPU.
  3. While it trains, open wandb and check every Episode_Reward/<penalty> is ≤ 0 (the sign rule from 3.4).
  4. uv run play … and watch. Write down one thing that looks unnatural.
  5. Export with scripts/export.py (never a hand-converted checkpoint), then drive it with scripts/infer_policy.py --walking output.onnx.
  6. Load it into your simulated duck: scripts/duck-sim ctl policy load walk <your HF repo> after uv run publish … --kind perpetual --slot walk.

Project 6 · Design a new trick (stretch, a weekend)

Make a "polite bow": from standing, lower the head, hold, and come back. Follow the repo's own workflow:

  1. Copy the closest template. A move ending in a pose means the StandUp config.
  2. Check in sim that the target pose is a stable equilibrium. Test tilt, not only height.
  3. Reward: one fixed target pose from t = 0 (Gaussian on joints and height), an impact penalty, and an upright term. No waypoints, because the policy camps at waypoints.
  4. Write a CPU config test that checks joint indices and reward signs. Then smoke test, then train.
  5. Publish as --kind episodic --duration-s 4.0.
Self-check rubric
  • Does any positive reward term still pay if the duck falls on its back? If yes, it will learn to fall.
  • Is there a smoothness penalty active from step 0? Move it later.
  • Does it work in the -Backlash- variant too?

6 · Pros and cons

Strengths

  • Skills are learned, not hand-coded. A new trick is a reward and a training run, not months of control theory.
  • The full sim2real recipe is open: actuator model, randomization, backlash, and the reward lessons.
  • The 61→14 contract makes policies swappable, community-publishable and installable with one command.
  • Serious systems engineering: safety authority in one place, recovery paths that survive failure, signed atomic updates with rollback.
  • A real simulator runs the real daemons, so you can develop without hardware.
  • Small and cheap enough to be a learning platform, but not a toy.

Limits

  • Training needs a CUDA GPU (or paid cloud jobs). Iterating on rewards is slow and costs money.
    Microduck strengths and limits StrengthsLimits Swappable 61→14 policiesReward trial and error Open sim2real recipeWeak hobby servos Real daemons in simDrivers absent in sim CUDA GPU or paid jobs
  • Reward design is still trial and error. The repo itself expects 2–5 rounds of reward-hacking fixes per new skill.
  • Policies are black boxes. When one fails on hardware, finding out why is hard.
  • Hobby servos are weak and imprecise. Backlash, heat and battery sag limit what is physically possible.
  • The simulator can't model drivers, radios, camera or NPU. Those bugs only show up on a real robot.
  • Policies are blind to most of the world. The ball-kick actor can't see the ball. Perception and planning on top are still mostly future work ("the autonomous brain" milestone).
  • Many moving parts: Rust daemons, Python training, Hugging Face, wandb, firmware. That's a lot to learn.

Check yourself

1. Which is a genuine limit of microduck's approach?

2. Why can community members share new gaits easily?

3. Why can't the ball-kick policy aim at the ball?

7 · What AI can't do here, so you can

The repo even ships AGENTS.md and CLAUDE.md files, because AI coding agents work in it every day. They are good at writing config boilerplate, reward functions, Rust IPC code and docs. These are the parts they still can't do, and where a person who understands this course is worth hiring:

Human and AI work split AI coding agent Config / rewards Rust IPC / docs Drafts for review Human engineer Measure hardware Judge motion Diagnose sim2real Own safety release Robot logs / rollout

GapWhy AI can't close itWhat you do
Ground truth from the physical robotAn agent can't hold a ruler or a multimeter. The repo records that a 5 mm error in standing height once made a goal impossible "for days".Measure: heights, masses, servo response on a test bench, battery sag under load. Feed real numbers into the model.
Judging the motionReward curves can rise while the duck "lies there shaking a leg". Spotting that needs eyes on the rollout and a sense of what looks wrong.Watch every run. Name the exploit. Turn the fix into a state-based gate, as in 3.4.
Character and taste"Expressive" isn't a number. The Disney BDX paper is about motion that feels alive.Decide what charming looks like, reference animation, and judge results the way an animator would.
Sim2real diagnosisWhen real and sim diverge, the cause is in hardware: a loose horn, a hot servo, a factory-reset ID on the bus.Use the comparison scripts (testbench_sim2real.py) and the robot's logs, and reason about the physical cause.
Driver and radio bugsThe simulator explicitly doesn't model the servo bus, BLE, camera or NPU.Debug on hardware with logic analysers, journalctl, and patience.
Safety and responsibilitySomeone must decide what the robot is allowed to do near a child, and sign a release. Agents can't carry that accountability.Set limits, review safety code, own the signing keys and the release decision.
Choosing what to buildThe repo's idea list is ranked by "charm-per-line". Knowing which ideas delight real people comes from watching real people.Test with users, as you already do with kids in AIRA, and pick the next skill from what you see.

A product idea from your own world. A learning companion that kids program by giving the duck goals ("walk to the block, then bow"), with policies from the Hub and a JSON-RPC client like Project 4. The AI writes the glue; you supply the pedagogy, the safety limits and the judgment of what kids actually enjoy.

Check yourself

1. Which job does the course say AI coding agents already do well here?

2. Reward curves rise but the duck lies on its back shaking a leg. Who catches it?

3. Why can't an agent fix most sim2real failures alone?

8 · Quiz

Take it the day after reading. Answers are shuffled and of similar length, so you have to know them.

0 / 0 answered

Reference shelf

WhatWhere
Robot repo and docspollen-robotics/microduck: start with docs/design/architecture.md, docs/design/robotd-design.md, docs/policy-manifest.md, docs/robot/simulation.md
Training repopollen-robotics/microduck_rl: README.md and AGENTS.md (the reward lessons)
Official policiesHugging Face: pollen-robotics/microduck-policies
Training frameworkmujocolab/mjlab; actuator model Rhoban/bam; CAD export Rhoban/onshape-to-robot
RL introOpenAI Spinning Up; Sutton & Barto 2nd ed.
PapersModule 2 table
Buy onepollen-robotics.com/microduck