Hugging Face Unveils Microduck: A $399 Open-Source 25 cm Biped You Train with Reinforcement Learning
Most robotics launches ask you to trust a demo video. Pollen Robotics, the Bordeaux robotics team at Hugging Face, is instead shipping the training loop. This week it opened pre-orders for Microduck, a 25 cm bipedal robot in which every movement — walking, sitting, kicking, roller-skating, standing back up after a fall — is a neural policy trained in a physics simulator and exported to the hardware. It costs $399. The training environments, the reward functions, the domain-randomization settings, and the sim-to-real recipe are all public on GitHub. Microduck follows Reachy Mini, which has shipped more than 10,000 units, but reverses its premise: where Reachy Mini was built to sit on a desk and interact, Microduck is built to leave the desk, fall over, and get back up.
The Hardware
Microduck is 25 cm tall, 14 cm wide, and under 800 g. It carries 15 motors across legs, neck, and head, plus an articulated beak that picks objects off the floor. Compute is a Rockchip RK3566 with an AI accelerator, 1 GB of RAM, and 32 GB of storage.
The sensor stack is unusually complete for the price. A front camera sits behind a dedicated camera-use indicator. Two IMUs are fitted, one in the body and one in the head. Range sensing is a compact LiDAR, an 8×8 time-of-flight matrix. There are microphones and a speaker, two NFC antennas, plus Wi-Fi and Bluetooth. Power is a removable NP-F550 battery, 2600 mAh, good for about an hour.
Seven trained moves ship in the box, driven by a bundled game controller before you write code: walk, sit and stand, kick, grab, roller-skate, and self-recovery. The robot does not speak. Each unit generates its own audio identity on first wake and keeps that voice permanently.
var STAGES = [
{ key:’MODEL’,
cap:’The duck exists twice. An Onshape CAD tree is exported to MJCF, and every servo is replaced by a physics model of the real Dynamixel XL330 rather than an ideal PD controller.’,
facts:[
‘MJCF models exported with onshape-to-robot; separate XMLs for walking, full–contact and roller tasks’,
’14 servo joints in the RL layout: 0–4 left leg, 5–8 neck and head, 9–13 right leg’,
‘Actuators use the BAM M6 model — voltage control law, back–EMF, Coulomb / Stribeck / load–dependent friction’
],
draw:function(){
return box(28,26,180,138)+label(38,44,’CAD → MJCF’,C.gr)+
duck(15,28,0.70,C.cr,false,.9)+
wire(‘M212 96 L300 96’,C.ln,true)+dots(‘M212 96 L300 96′,C.or,2)+
box(306,26,286,138)+label(316,44,’ACTUATOR MODEL · XL330′,C.gr)+
label(320,72,’voltage control law’,C.cr,11)+
label(320,92,’back–EMF’,C.cr,11)+
label(320,112,’Coulomb / Stribeck friction’,C.cr,11)+
label(320,132,’load–dependent friction’,C.cr,11)+
‘
‘
‘
‘
}},
{ key:’RANDOMIZE’,
cap:’Pollen breaks the simulator on purpose. Each parallel environment gets different battery voltage, sag, latency and friction, and a backlash twin adds gear play the encoder has to read through.’,
facts:[
‘Per–env randomization on battery voltage, voltage sag under load, command delay and friction magnitude’,
‘Backlash variants train with ±1° of gear play — 2° total — in series with each of the 14 servo joints’,
‘Observation and action dimensions are unchanged, so the ONNX export and the onboard runtime need no edits’
],
draw:function(){
var rows=[[‘battery voltage’,C.or],[‘voltage sag’,C.sk],[‘command delay’,C.lv],[‘friction’,C.gn],[‘±1° backlash’,C.cr]];
var s = box(28,26,262,138)+label(38,44,’PER–ENV PARAMETERS’,C.gr);
for(var i=0;i
if(dr){
s+=’
}else{
s+=’
}
}
s+=wire(‘M294 96 L344 96’,C.ln,true)+dots(‘M294 96 L344 96′,dr?C.or:C.gr,2);
s+=box(350,26,242,138)+label(360,44,dr?’ROBUST POLICY’:’BRITTLE POLICY’,dr?C.or:’#C05B3C’);
s+=duck(344,30,0.62,dr?C.cr:C.gr,true,1);
s+=label(462,100,dr?’survives real’:’sim only’,’#BFB3A4′,11);
s+=label(462,118,dr?’hardware’:”,’#BFB3A4′,11);
return s;
}},
{ key:’TRAIN’,
cap:’Thousands of ducks learn at once. Training runs on mjlab, the MuJoCo Warp framework, with PPO on a CUDA GPU — or on Hugging Face Jobs when there is no local GPU.’,
facts:[
‘uv run train Mjlab-Velocity-Flat-MicroDuck --env.scene.num-envs 4096‘,
‘Pollen reports roughly 1–2 hours for a usable gait at 4096 environments’,
‘No GPU? Append --hf-jobs to any train command to run it on Hugging Face Jobs’
],
draw:function(){
return box(28,26,178,138)+label(38,44,’PPO · mjlab’,C.gr)+
label(38,72,’4096 envs’,C.or,15)+
label(38,96,’50 Hz control’,C.cr,11)+
label(38,116,’MuJoCo Warp’,C.cr,11)+
label(38,140,’~1–2 h to a gait’,’#BFB3A4′,10)+
wire(‘M210 96 L222 96′,C.ln)+
grid();
}},
{ key:’EXPORT’,
cap:’One file crosses the gap. The exporter bakes the observation normalizer straight into the ONNX graph, which is why hand–converted checkpoints are explicitly warned against.’,
facts:[
‘uv run scripts/export.py Mjlab-Velocity-Flat-MicroDuck produces the deployable artifact’,
‘The normalizer ships inside the graph — a hand–converted checkpoint would see unnormalized observations’,
‘Same file format for every behavior, which is what makes runtime swapping possible’
],
draw:function(){
return box(28,40,150,110)+label(38,60,’CHECKPOINT’,C.gr)+label(38,104,’model_29999.pt’,C.cr,11)+
wire(‘M182 95 L246 95’,C.ln,true)+dots(‘M182 95 L246 95′,C.or,2)+
box(252,40,150,110,C.or)+label(262,60,’EXPORT’,C.or)+
label(262,92,’normalizer’,C.cr,11)+label(262,110,’baked in’,C.cr,11)+
‘
wire(‘M406 95 L470 95’,C.ln,true)+dots(‘M406 95 L470 95′,C.sk,2)+
box(476,40,116,110)+label(486,60,’ARTIFACT’,C.gr)+
label(486,100,’.onnx’,C.sk,17);
}},
{ key:’DEPLOY’,
cap:’The policy runs onboard at 50 Hz on a Rockchip RK3566. A Rust daemon owns the control loop and the motor bus, and swaps policies behind one shared observation contract.’,
facts:[
‘Onboard compute is a Rockchip RK3566 with 1 GB RAM and 32 GB storage, driving 15 motors’,
‘robotd owns the 50 Hz loop; daemons talk over one JSON–RPC contract on Unix sockets’,
‘Walk, recover and trick policies share a 61–dim observation, so any can take over at any moment’
],
draw:function(){
return box(28,26,196,138)+label(38,44,’RK3566 · robotd’,C.gr)+
label(38,74,’50 Hz’,C.or,19)+label(38,98,’control loop’,C.cr,11)+
label(38,124,’15 motors · 1 GB RAM’,’#BFB3A4′,10)+
label(38,144,’32 GB storage’,’#BFB3A4′,10)+
wire(‘M228 70 L300 70’,C.ln)+dots(‘M228 70 L300 70’,C.or,2)+
wire(‘M300 122 L228 122’,C.ln)+dots(‘M300 122 L228 122′,C.sk,2)+
label(238,62,’actions’,C.or,9)+label(234,140,’observations’,C.sk,8)+
box(306,26,286,138)+label(316,44,’THE REAL ROBOT’,C.gr)+
duck(330,26,0.85,C.cr,true,1)+
label(470,86,’camera · LiDAR’,’#BFB3A4′,10)+
label(470,104,’2 IMUs · beak’,’#BFB3A4′,10)+
label(470,122,’NFC · mic · speaker’,’#BFB3A4′,10);
}}
];
function render(){
var s = STAGES[step];
svg.innerHTML = s.draw();
key.textContent = s.key;
cap.innerHTML = s.cap;
facts.innerHTML = s.facts.map(function(f){return ‘
‘;}).join(”);
resize();
}
var tabs = root.querySelectorAll(‘.dk-step’);
for(var i=0;i
‘3 · twist command. The velocity you send from the gamepad: where the duck should go and how fast it should turn.’,
‘4 · head pose command. Where the head should point, commandable independently while the legs keep walking.’,
‘6 · body pose command. Trunk attitude, used by the sit, stand and crouch policies. Tasks that ignore a slot zero–pad it rather than dropping it.’
];
var obsOut = document.getElementById(‘dkobs’);
var segs = root.querySelectorAll(‘.dk-seg’);
function setObs(k){
for(var j=0;j
‘Loaded Mjlab-StandUp-Flat-MicroDuck — stands up from face–down, face–up or sitting, then holds the stand.’,
‘Loaded Mjlab-SitStand-Flat-MicroDuck — commanded sit and stand in a single policy, gently, head still commandable.’,
‘Loaded Mjlab-GroundPick-Flat-MicroDuck — crouches, touches the ground with the mouth tip, returns to stand.’,
‘Loaded Mjlab-BallKick-Flat-MicroDuck — kicks a 70 mm, 15 g ball forward. The actor is ball–blind: it never sees the ball in its observation.’,
‘Loaded Mjlab-Roulade-Flat-MicroDuck — a forward roll over the head, landing back on the feet.’,
‘Loaded Mjlab-Velocity-Flat-MicroDuck-Rollers — velocity tracking on passive wheels clipped under the feet.’
];
var polOut = document.getElementById(‘dkpol’);
var chips = root.querySelectorAll(‘.dk-chip’);
function setPol(k){
for(var j=0;j