The robot framework finally lets you write a while loop. Here is what changed, in plain language.
A command is a piece of robot behavior you package up now and let something else run later — when a button is pressed, when autonomous starts, when a sensor trips. “Drive forward two meters.” “Raise the elevator.” “Spin the shooter up and fire.”
Commands exist because a robot does several things at once, and because two pieces of code must never fight over the same motor. If one part of your program says arm up while another says arm down, the arm does something nobody intended. So each command declares which mechanisms it needs, and while it runs, it owns them. The scheduler runs everything and enforces the rules.
That much has been true since 2012. V1 shipped that season, V2 arrived in 2020, and V3 is the version for 2027. Triggers, commands, mechanisms, and the scheduler all survive the jump. What changes is how you write the inside of a command.
while loop used to be illegalRobot code runs one loop, about every 20 milliseconds. Every command that is currently scheduled gets a short slice of that loop, then control returns to the scheduler so the next command gets its slice. Nothing is running in parallel — they take turns, fast.
In V2 a command could not pause in the middle of itself. So if you wrote a normal while loop inside one, it never gave the slice back, and every other command stopped: your drivetrain kept its last command, your arm stopped being held, and the robot kept rolling. That is the actual reason the old framework made you chop your logic into four separate methods.
Drag the switch below. Same code, one line different.
Fig. 1One 20 ms cycle of the robot loop. With yield() the two commands take turns; without it the first one never gives the cycle back and nothing else runs at all.
An ordinary function runs from top to bottom and then returns. You get one shot at it. A coroutine is a function that can stop partway through, hand control back, and later pick up exactly where it left off — same local variables, same place in the loop. Stopping is called yielding.
That is the whole trick. Because a V3 command body is a coroutine, you can write a loop and simply say “I have done my work for this cycle” at the bottom of it. The scheduler resumes you 20 ms later on the next line.
Called, runs to completion, returns. Nothing else happens in between — and in robot code, “nothing else” includes stopping the drivetrain.
Runs a slice, suspends at yield(), lets everyone else work, resumes on the next line. Its variables and its position in the loop are still there.
Fig. 2The whole difference in one picture: a normal function occupies every moment it runs, while a coroutine hands control back in the middle and later picks up exactly where it stopped.
Pausing is the word that trips people up, so here is what actually happens, line by line, when driveDistance runs. Java saves your place — which statement you were on, what your local variables held, how far through the loop you were — and restores all of it when the scheduler resumes you.
| Clock | What your command body does | start | Motor |
|---|---|---|---|
| 0 ms | Reads the encoder into start, enters the loop, sets the motor, reaches yield() and suspends. |
0.00 | 0.5 |
| 0 ms | Paused. Every other command gets its slice, telemetry is logged, the loop closes. The robot is still driving. | 0.00 | 0.5 |
| 20 ms | Resumes on the line after yield() — which is the bottom of the loop, so it rechecks the condition and goes around again. start is still there. |
0.00 | 0.5 |
| 40 ms | Same again. And every 20 ms after that. | 0.00 | 0.5 |
| 380 ms | The encoder finally reads past target, so the loop condition is false. Execution falls through to motor.set(0.0) and the command finishes. |
0.00 | 0.0 |
motor.set(0.5) stays in effect until something changes it. Your code is paused; the hardware is not.execute() again and again, so nothing could survive between cycles and every variable had to become a field.Thread.sleep(). Nothing blocks and no time is wasted. Pausing is exactly what gives every other command room to run.Here is the code a first-year student would write in any programming class: read the encoder, drive until you have gone far enough, stop.
public void driveDistance(double target) { double start = encoder.getDistance(); while (encoder.getDistance() - start < target) { motor.set(0.5); } motor.set(0.0); }
class DriveDistance extends Command { private double start; public void initialize() { start = encoder.getDistance(); } public void execute() { motor.set(0.5); } public boolean isFinished() { return encoder.getDistance() - start >= target; } public void end(boolean interrupted) { motor.set(0.0); } }
Command driveDistance(double target) { return drive.run(coroutine -> { double start = encoder.getDistance(); while (encoder.getDistance() - start < target) { motor.set(0.5); coroutine.yield(); } motor.set(0.0); }).named("Drive Distance"); }
drive.run(...) claims the drivetrain; .named(...) finishes the command.Two rules fall out of that example, and the compiler enforces both.
Say autonomous should drive a square: go straight, turn 90°, four times. In V2 that was a sequential group of eight chained commands. In V3 it is a for loop, because await starts a command and waits for it to finish.
Command driveSquare(double sideLength) { return drive.run(coroutine -> { for (int i = 0; i < 4; i++) { coroutine.await(driveDistance(sideLength)); coroutine.await(turn(Degrees.of(90))); } }).named("Drive Square"); } // No yield() needed here — await yields for you until the child is done.
These are the coroutine methods. There are only a handful, and most robot code uses three of them.
Seconds.of(5). Internally: a timer and a yielding loop.A command requires zero or more mechanisms and owns them while it runs. Schedule a second command that needs the same mechanism and one of them has to stop — that is the point. Which one stops depends on priority.
V2 gave you two settings: interruptible, or not. V3 gives you an integer, so “this matters more than that” is something you can express directly. A command can always be interrupted by an equal or higher priority command, and never by a lower one. Leave priority alone and you get the familiar behavior: equal priorities interrupt each other.
The bigger fix is in groups. In V2, a sequence owned everything it would ever touch, for its entire run — so “drive, then raise the arm, then open the gripper” held the arm hostage while the gripper worked, and no default command could hold the arm up. It sagged. Teams reached for ProxyCommand, which had its own sharp edges.
In V3 the scheduler can see the whole tree, so a parent only owns a mechanism while the child using it is actually running. Interrupt a child and the cancellation bubbles all the way up and cancels the whole composition — you never end up with half a sequence limping forward on a false assumption.
Command driveThenLift() {
return Command.noRequirements(coroutine -> {
coroutine.await(drive.driveDistance(Meters.of(2))); // owns the drivetrain here
coroutine.await(elevator.moveToTop()); // owns the elevator here
}).named("Drive Then Lift");
}The parent requires nothing, but while its child runs, the drivetrain is effectively spoken for. The elevator is untouched, so its default command can hold it up — exactly what V2 could not do.
Fig. 3Ownership follows the child that is actually running, not the whole composition. Press the third button to interrupt the drivetrain mid-drive and watch the cancellation travel up the tree.
In V1 and V2, every trigger binding you set up lived forever, so keeping one from firing at the wrong moment meant piling conditions onto it. V3 adds scope: anything you create inside a command — a trigger, a forked command, a default command — exists only while that command runs, and is torn down when it ends.
That turns an awkward problem into three lines. Start shooting when the robot enters the shot zone, but only during this one path.
Command sweepLeft() {
return Command.noRequirements(coroutine -> {
// Alive only inside this command.
new Trigger(this::inShotZone).whileTrue(shooter.shootOnTheMove());
coroutine.await(drive.followPath("Left Trench Sweep"));
}).named("Sweep Left");
}
// Path finishes → command exits → trigger is unbound and collected.The same scoping runs one level up, in op modes — the 2027 replacement for autonomous choosers and test mode. An op mode is a class the driver station lets you pick, and whatever you set up in its constructor applies only while it is selected. A competition teleop and a demo teleop for letting four-year-olds drive can have completely different bindings without a single if.
// In the robot class: true at all times. drive.setDefaultCommand(drive.stop()); xbox.a().whileTrue(drive.driveSquare(1.0)); // In a teleop op mode: true only while this mode is selected. @Teleop(name = "Competition") class CompetitionTeleop extends OpMode { CompetitionTeleop(Drive drive, CommandXboxController xbox) { drive.setDefaultCommand(drive.driveWithJoysticks(xbox)); xbox.x().whileTrue(drive.setX()); } } // Mode ends → its default command and bindings pop off, globals return.
Nothing is selected yet, so only the global bindings are live.
Fig. 4Three nested scopes. An inner scope overrides the outer default while it lasts, and everything it created disappears when it ends — no bookkeeping, and no leftover trigger firing in the wrong match.
The V2 scheduler only knew about the outermost command. Inside a group it was a black box — if you wanted to know what was running you added print statements. The V3 scheduler knows everything, and publishes it.
scheduler.run() eats half your loop, you can now point at the command responsible instead of blaming WPILib.All of it is protobuf-serialized, which means the standard tools — DataLog, Glass, the SimGUI, AdvantageScope, Epilog — read it without you writing listeners.
| ID | Parent | Command |
|---|---|---|
| 325 | — | Drive Square |
| 328 | 325 | Drive Distance |
| 329 | 325 | Turn 90° |
| 330 | 325 | Drive Distance |
| 331 | 325 | Turn 90° |
every run gets a fresh id · V2 only ever saw row one
Fig. 5Because every command reports its own id and its parent’s, a tool can rebuild the tree and draw the parent as a bar containing its children. In V2 the scheduler only knew about the top bar, which is why debugging a group meant adding print statements.
V3 is deliberately layered. A rookie and a veteran can work in the same codebase without either writing something they do not understand.
New to programming or new to WPILib. Loops, if, switch, and yield(). No framework vocabulary beyond run and named.
Simple A-then-B-then-C sequences, the V2 way. Groups own every requirement for their whole life, which is blunt but safe.
Branching, loops, background work: fork, await, scoped triggers, nested default commands.
For genuinely stateful mechanisms — a scoring superstructure with home, L1–L4, and transitions between them.
This one is described in the talk but is not in the published alpha API docs yet, so no code is shown here — anything I wrote would be invented syntax. What was described: each state is an ordinary command; you declare one initial state; transitions fire either when a state's command completes or when a condition you supply becomes true; and if a state finishes with no transition out of it, the whole machine finishes. The compiler plugin refuses to build a state machine with no initial state, the same way it refuses a loop that never yields.
V2 needed a command type for every control-flow idea, because commands were the only thing the scheduler understood. Once a command body is ordinary Java, most of them stop being necessary.
| V2 | Existed because | V3 |
|---|---|---|
| ConditionalCommand | You could not write an if. | Write an if. |
| SelectCommand | You could not write a switch. | Write a switch. |
| WaitCommand | Waiting had to be a command. | coroutine.wait(Seconds.of(2)) |
| DeferredCommand | Commands were built once, up front. | Build the command inside the body — it is created when it runs. Cache it yourself if construction is expensive. |
| ProxyCommand | Groups over-owned their requirements. | Not needed. Children behave like proxies by default, and cannot interrupt their parents. |
Write the code you would write anywhere else — loops, conditions, waits — and add coroutine.yield() at the bottom of every loop so the rest of the robot gets its turn.