Motivation
Anyone who has taught FRC programming has had to explain why a perfectly normal while loop is not allowed inside a command, and why the logic has to be chopped into four methods instead. Commands V3, the version of WPILib’s command framework for the 2027 season, removes that rule. It is in alpha now and is planned to ship for kickoff 2027.
These notes are based on Sam Carlberg’s talk, “Commands V3: Imperative control with coroutines”. Sam is lead mentor for FRC 2084 and a core WPILib developer. In this post I want to walk through what changed, starting from what a command is, so that it works both for students who are new to commands and for mentors coming from V2. Three of the figures along the way are interactive.
What a command is
A command is a piece of robot behavior that you package up now and let something else run later, for example when a button is pressed, when autonomous starts, or when a sensor trips. “Drive forward two meters”, “raise the elevator” and “spin the shooter up and fire” are all commands.
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.
Why your while loop used to be illegal
Robot code runs one loop, about every 20 milliseconds. Every command that is currently scheduled gets a short slice of that loop, and then control returns to the scheduler so that the next command gets its slice. Nothing runs in parallel; the commands take turns, very quickly.
In V2 a command could not pause in the middle of itself. If you wrote a normal while loop inside one, it never gave its 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 real reason the old framework made you chop your logic into four separate methods.
You can see this in the figure below. Use the switch to flip between the same code with and without one line, the call to yield().
Both commands run every cycle. The drive gets its slice, the arm gets its slice, and the loop closes on schedule.
holdArm() has run 0 times.
yield() the two commands take turns. Without it, the first command never gives the cycle back and nothing else runs at all.A coroutine is a function that can pause
An ordinary function runs from top to bottom and then returns, so 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, with the same local variables and at the same place in the loop. Stopping like this is called yielding.
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, and resumes on the next line. Its variables and its position in the loop are still there.
“Pause” means bookmark, not stop
Pausing is the word that trips people up, so here is what actually happens, line by line, when driveDistance runs. You can think of it as a bookmark. 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 |
It is just as useful to be clear about the four things pausing does not mean:
- It does not stop the motor.
motor.set(0.5)stays in effect until something changes it. Your code is paused; the hardware is not. - It does not release the drivetrain. The command is still scheduled and still owns its mechanisms, even though it is not executing at this instant. Nothing else can claim them without interrupting it.
- It does not restart the command from the top. This is the main difference from V2, where the scheduler called
execute()again and again, so nothing could survive between cycles and every variable had to become a field. - It is not
Thread.sleep(). Nothing blocks and no time is wasted. Pausing is exactly what gives every other command room to run.
Drive a distance, three ways
Let’s say you want the robot to drive a set distance. Here is the code a first-year student would write in any programming class: read the encoder, drive until you have gone far enough, then stop.
public void driveDistance(double target) { double start = encoder.getDistance(); while (encoder.getDistance() - start < target) { motor.set(0.5); } motor.set(0.0); }
On a robot this locks up the whole program until it finishes. Nothing else gets a slice.
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); } }
The loop is gone. Its top, body, condition and cleanup live in four places, and the scheduler stitches them back together.
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"); }
Your original logic, plus one line. drive.run(...) claims the drivetrain, and .named(...) finishes the command.
Two rules fall out of that example, and the compiler enforces both of them.
- Every loop must yield. A WPILib compiler plugin looks for loops that have a coroutine in scope and refuses to compile if you never yield, park or await inside one, so you cannot forget.
- Every command must be named. The library will not invent a name for you, because that name is what you will be reading in telemetry at 11pm in the pit.
Waiting and sequences
For example, 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 of them, and most robot code uses three.
- coroutine.yield()
- Pause here, let everything else run this cycle, and resume on the next line next cycle. This is the one you will use most.
- coroutine.await(cmd)
- Start a command and wait for it to finish before continuing. It schedules the command automatically if it is not already running.
- coroutine.awaitAll(…)
- Wait for all of the given commands to finish. This is what a V2 parallel group did.
- coroutine.awaitAny(…)
- Wait for the first one to finish, then cancel the rest. This is what a V2 race group did.
- coroutine.fork(cmd)
- Start a command in the background and keep going immediately. It cannot outlive the command that forked it.
- coroutine.wait(time)
- Wait a duration, for example
Seconds.of(5). Internally it is a timer and a yielding loop. - coroutine.waitUntil(cond)
- Yield until a boolean condition becomes true.
- coroutine.park()
- Yield forever. The command holds its mechanisms and never finishes on its own, which is useful when a trigger inside it is doing the real work.
- coroutine.scheduler()
- An escape hatch to the scheduler itself. Advanced, and rarely needed.
Who owns the arm, and who gets interrupted
A command requires zero or more mechanisms and owns them while it runs. If you schedule a second command that needs the same mechanism, one of them has to stop, which is the point of requirements. Which one stops depends on priority.
V2 gave you two settings, interruptible or not. V3 gives you an integer, so you can express “this matters more than that” directly. A command can always be interrupted by an equal or higher priority command, and never by a lower one. If you leave priority alone you get the familiar behavior, where 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, no default command could hold the arm up, and 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. If a child is interrupted, the cancellation bubbles all the way up and cancels the whole composition, so 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");
}
While the first line runs, something else that needs the drivetrain will interrupt it, and that cancels this whole command. Once it moves on to the elevator, the drivetrain is free again.
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, which V2 could not do.
Triggers that clean up after themselves
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, whether it is a trigger, a forked command or a default command, exists only while that command runs and is torn down when it ends.
That turns an awkward problem into three lines. Let’s say you want to start shooting when the robot enters the shot zone, but only during one particular 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.
Seeing what the scheduler does
The V2 scheduler only knew about the outermost command. Inside a group it was a black box, and if you wanted to know what was running you added print statements. The V3 scheduler knows everything, and publishes it.
- Names and IDs. Every run of a command gets a fresh unique ID, so two presses of the same button are two distinguishable runs in your logs.
- Parent IDs. Each command records who started it, so tools like AdvantageScope can rebuild the whole tree and draw Drive Square as a bar containing its four drives and four turns.
- Real timings. Elapsed CPU time per run and a running total, per command. When
scheduler.run()eats half your loop, you can now point at the command responsible instead of blaming WPILib. - Events, not just snapshots. Scheduled, mounted, yielded, completed, completed-with-errors, cancelled and interrupted, each timestamped when it happened. A command that starts and finishes inside a single loop never appears in a snapshot, but you still catch its events.
- Cached triggers. A trigger’s condition is read once per scheduler run and reused, so the value you log is the value that actually decided whether a command started. Noisy sensors no longer produce logs that contradict themselves.
All of it is protobuf-serialized, which means the standard tools (DataLog, Glass, the SimGUI, AdvantageScope and Epilog) read it without you writing listeners.
Four ways to write robot code
V3 is deliberately layered, so that a rookie and a veteran can work in the same codebase without either of them writing something they do not understand. You can think of it as four levels:
- Level 1, imperative. For students new to programming or new to WPILib: loops,
if,switchandyield(), with no framework vocabulary beyondrunandnamed. - Level 2, declarative. Simple A-then-B-then-C sequences, the V2 way. Groups own every requirement for their whole life, which is blunt but safe.
- Level 3, async. Branching, loops and background work, using
fork,await, scoped triggers and nested default commands. - Level 4, state machine. For genuinely stateful mechanisms, such as a scoring superstructure with home, L1–L4, and transitions between them.
About the state machine API
The state machine API is described in the talk but is not in the published alpha API docs yet, so I have not shown any code for it here; anything I wrote would be invented syntax.
Here is what was described. Each state is an ordinary command, and 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, in the same way it refuses a loop that never yields.
Coming from V2
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. Here are five you can now forget.
| 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, where 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. |
What ships, and when
Before you rewrite everything, a few caveats.
- 2027, not this season. V3 needs JVM features the roboRIO could not provide, so it targets the new control system. Alpha releases are on GitHub now and install through the normal WPILib installer.
- Java only, for now. The coroutine support hooks into Java internals. Python and C++ ports are hoped for around 2028. Java was used by 93% of FRC teams in the 2026 season, so it went first.
- The API is still moving. Names and signatures in these examples come from the 2027 alphas and the talk, and you should expect some to shift before kickoff. Check the API docs before copying.
- V2 knowledge still counts. Triggers, requirements, default commands and interruption all carry over. You are learning a better way to write command bodies inside the same framework.
If I had to give a student one piece of advice from all of this, it would be to write the code you would write anywhere else, with loops, conditions and waits, and to add coroutine.yield() at the bottom of every loop so that the rest of the robot gets its turn.
Sources
Sam Carlberg. Commands V3: Imperative control with coroutines. Session recording.
WPILib 2027 development API docs, org.wpilib.command3.
Chief Delphi, the original Commands V3 thread.
Companion guides: SystemCore and the 2027 transition, From your hands to the wheels (how the whole system connects), and Fifteen seconds (autonomous and Choreo for beginners).