WPILib · FRC 2027 · Java

Commands V3

The robot framework finally lets you write a while loop. Here is what changed, in plain language.

Alpha — ships for kickoff 2027
Based on the talk “Commands V3: Imperative control with coroutines” Sam Carlberg · lead mentor, FRC 2084 · core WPILib developer Watch the session →
Start here

What a command is, if nobody has told you yet

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.

The problem V3 solves

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, 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.

Loop time 20.0 ms Both commands run every cycle. The drive gets its slice, the arm gets its slice, telemetry gets logged, and the loop closes on schedule.
Arm holding? yes holdArm() has run 0 times.

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.

The idea

A coroutine is a function that can pause

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.

Ordinary function
main
function

Called, runs to completion, returns. Nothing else happens in between — and in robot code, “nothing else” includes stopping the drivetrain.

Coroutine
scheduler
command

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.

“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. 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.

ClockWhat your command body doesstartMotor
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
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 — it is simply 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 whole 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.
Your first command

Drive a distance, written three ways

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.

Plain Java — what you would naturally write1 of 3
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.
Commands V2 — torn into four methods2 of 3
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.
Commands V3 — the loop stays a loop3 of 3
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; .named(...) finishes the command.

Two rules fall out of that example, and the compiler enforces both.

  • 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. You cannot forget.
  • Every command must be named. The library will not invent a name for you, because that name is what you will read in telemetry at 11pm in the pit.
Sequences

Waiting for another command is just a line of code

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.

driveSquare
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.

coroutine.yield()
Pause here, let everything else run this cycle, resume on the next line next cycle. The one you will use most.
coroutine.await(cmd)
Start a command and wait for it to finish before continuing. Schedules it 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, e.g. Seconds.of(5). Internally: 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 — useful when a trigger inside it is doing the real work.
coroutine.scheduler()
Escape hatch to the scheduler itself. Advanced, rarely needed.
Ownership

Who owns the arm, and who gets interrupted

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.

A parent that requires nothing and still owns things, briefly
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 needing the drivetrain will interrupt it — and that cancels this whole command. Once it moves on to the elevator, the drivetrain is free again.
Drive Then Liftrunningrequires nothing itself
driveDistance()runningrequires drivetrain
elevator.moveToTop()not startedrequires elevator
Drivetrainowned by Drive Distance
Elevatorfree — default command holds it

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.

The author's favorite part

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 — 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.

A trigger scoped to one autonomous move
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.

Global defaults, overridden for one op mode
// 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.
Robot class — always drive default: stop() A button → Drive Square
@Teleop “Competition” — while selected drive default: driveWithJoysticks() X button → set X
sweepLeft() — while this command runs in shot zone → shoot on the move

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.

Debugging

You can finally see what the scheduler is doing

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.

  • 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, 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, Epilog — read it without you writing listeners.

IDParentCommand
325Drive Square
328325Drive Distance
329325Turn 90°
330325Drive Distance
331325Turn 90°

every run gets a fresh id · V2 only ever saw row one

Drive Square  #325
drive #328turn #329 drive #330turn #331 drive #332turn #333

time →

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.

Pick your level

Four ways to write the same robot code

V3 is deliberately layered. A rookie and a veteran can work in the same codebase without either writing something they do not understand.

Level 1

Imperative

New to programming or new to WPILib. Loops, if, switch, and yield(). No framework vocabulary beyond run and named.

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, background work: fork, await, scoped triggers, nested default commands.

Level 4

State machine

For genuinely stateful mechanisms — a scoring superstructure with home, L1–L4, and transitions between them.

About the state machine API

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.

Coming from V2

Five command types you can now forget

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.

V2Existed becauseV3
ConditionalCommandYou could not write an if.Write an if.
SelectCommandYou could not write a switch.Write a switch.
WaitCommandWaiting had to be a command.coroutine.wait(Seconds.of(2))
DeferredCommandCommands were built once, up front.Build the command inside the body — it is created when it runs. Cache it yourself if construction is expensive.
ProxyCommandGroups over-owned their requirements.Not needed. Children behave like proxies by default, and cannot interrupt their parents.
Before you rewrite everything

What is actually shipping, and when

  • 2027, not this season. V3 needs JVM features the roboRIO could not provide; 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 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; 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, not a new framework.
If you take one thing away

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.