The Command Scheduler
The Engine Underneath Everything
The default scheduler, Scheduler.getDefault(), is the object that actually runs commands — everything in Lessons 25.1-25.2 depends on it silently doing its job every single loop. Every mechanism and trigger uses it unless you deliberately create a separate one (Scheduler.createIndependentScheduler(), mostly useful in tests). It must be told to run explicitly, from robotPeriodic() (Lesson 25.7 shows exactly where), or nothing in the whole command-based framework ever executes:
@Override
public void robotPeriodic()
{
Scheduler.getDefault().run();
}
In v2 code you'll see CommandScheduler.getInstance().run() in this same spot — same job, older name.
One Scheduler Loop, Step by Step
Every 20ms (WPILib's default loop rate), one call to run() works through these steps, always in this order (slightly simplified from the source):
- Clean up. Cancel any commands and trigger bindings whose scope has ended (see "Scopes" below). While the robot is disabled, also cancel commands on mechanisms that aren't allowed to move while disabled.
- Run periodic side jobs added with
addPeriodic(...)— data updates such as odometry or logging. - Poll triggers. Every registered
Trigger(Lesson 25.6) is checked. A binding whose condition changed queues its command — it doesn't start it yet — or cancels a command it started earlier. - Queue default commands for every mechanism that has nothing running or queued.
- Start the queued commands. Requirement conflicts are settled here, by priority (next section). A running command that loses its mechanism is canceled now. If two queued commands with the same priority need the same mechanism, the one queued last wins.
- Run every running command, one at a time, until each one calls
coroutine.yield()or finishes.
Because triggers only queue commands in step 3, a command bound to a button starts in step 5 of the same run() and gets its first turn in step 6 — still within the same loop. One exception: a command started from inside another command's body (with coroutine.await(...) or coroutine.fork(...)) skips the queue and starts immediately.
Priorities: Who Wins a Requirement Conflict
Every command has an integer priority. The default is Command.DEFAULT_PRIORITY (0); the extremes are Command.LOWEST_PRIORITY and Command.HIGHEST_PRIORITY. When a new command needs a mechanism that a running command already has, the scheduler compares them:
- New command's priority is equal or higher — the running command is canceled and the new one takes over.
- New command's priority is lower — the new command simply isn't scheduled, and the running one keeps going.
Priority is set while building the command, with .withPriority(...):
public Command lowBatteryWarning()
{
return run(coroutine -> {
showRed();
coroutine.wait(Seconds.of(2));
}).withPriority(10).named("Low Battery Warning"); // beats any priority-0 LED command
}
In v2 code you'll see this job done by an "interruption behavior" instead: InterruptionBehavior.CANCEL_SELF (the running command gives way) or CANCEL_INCOMING (the new command is refused) — spelled kCancelSelf/kCancelIncoming in code from 2026 and earlier.
Default Commands
A mechanism's default command runs whenever no other command requires that mechanism. If you never set one, the mechanism simply sits unused whenever nothing requires it. You set one with setDefaultCommand(...), once, during setup:
drive.setDefaultCommand(
drive.runRepeatedly(() -> drive.arcade(forward.getAsDouble(), turn.getAsDouble()))
.withPriority(Command.LOWEST_PRIORITY)
.named("Arcade Drive"));
Give a default command a priority below the default — usually LOWEST_PRIORITY — so any ordinary command can take the mechanism from it. Once that other command ends, the next time step 4 runs it queues the default command again, and the default command starts over from the top of its body. A default command must require its own mechanism and no other; setDefaultCommand throws an error otherwise.
This "runs every loop, lowest priority, gives way to anything else" shape is exactly what Lesson 25.5 uses to run a mechanism's state machine.
How a Command Ends: Finished or Canceled
A command finishes when its body returns. It is canceled when something stops it early: a whileTrue button is released, an until(...) condition or timeout fires, a higher-or-equal-priority command takes its mechanism, or code calls Scheduler.getDefault().cancel(command). A canceled command stops wherever it last yielded — the code after its loop never runs.
That's why cleanup belongs in whenExited(...) (runs on every exit) or whenCanceled(...) (runs only when stopped early) from Lesson 25.2. A class that implements Command directly gets the same two hooks as the methods onExit() and onCancel(). In v2 code you'll see a single end(boolean interrupted) method doing both jobs.
Scopes: Commands That Clean Up After Themselves
v3 tracks the scope a command or trigger binding was created in, and cancels it automatically when that scope ends. A command scheduled during autonomous is canceled when autonomous ends; a trigger binding created inside an opmode (Lesson 25.7) only works while that opmode is selected; a command started by another command never outlives it. So there's no "remember to cancel the autonomous command when teleop starts" step to forget.
Alpha note: scopes are one of the newest parts of v3 (the robot-mode scopes landed in September 2026), so the exact rules may still change.
Periodic Side Jobs: addPeriodic Is for Data, Not Motors
v3 mechanisms have no periodic() method. A mechanism's getters usually just read their sensor when called, so a command always sees a fresh value. For work that has to happen every loop but doesn't control anything — updating odometry, logging, filling an LED data buffer — register a side job once during setup:
Scheduler.getDefault().addPeriodic(() -> drive.updateOdometry());
Side jobs run in step 2, before triggers and commands, so commands in step 6 see this loop's data. They must never drive a motor, though: a side job requires no mechanisms, so it would bypass the whole requirement system. Anything that controls a mechanism every loop belongs in a command — often its default command.
Common Pitfalls
- Forgetting to call
Scheduler.getDefault().run()inrobotPeriodic(). Without this one line, nothing in the entire command-based framework does anything — commands never run, triggers never fire. - Assuming a trigger starts its command the instant its condition changes. The trigger only queues the command in step 3; it starts in step 5 — and if another command with the same priority that needs the same mechanism is queued later in that loop, the later one wins.
- Putting cleanup after the loop instead of in
whenExited(...). A canceled command never reaches the code after its loop, so a motor started by the command keeps running unless the cleanup is inwhenExited(...)orwhenCanceled(...). - Controlling a motor from an
addPeriodic(...)side job. Side jobs have no requirements, so they fight whatever command owns that mechanism; use a command (usually the default command) instead.
Key Takeaways
- The default
Scheduler(Scheduler.getDefault()) must be run explicitly, fromrobotPeriodic()— without this call, the framework does nothing. - Each
run()goes in a fixed order: clean up ended scopes, runaddPeriodicside jobs, poll triggers (which queue commands), queue default commands, start queued commands (settling conflicts), then run each command until it yields or finishes. - Requirement conflicts are settled by priority: an equal-or-higher-priority command takes over, a lower-priority one is refused.
- A default command runs whenever its mechanism is free; give it
LOWEST_PRIORITYso any other command can take over. - Cleanup goes in
whenExited(...)/whenCanceled(...), because a canceled command stops where it last yielded; scopes cancel autonomous- and opmode-scoped commands automatically. addPeriodic(...)is for data updates only — never for controlling a mechanism.
Derived from other-reference-repo: wpilib/command-based/08-command-scheduler.md (WPILIB 20.8); updated for Commands v3 from allwpilib main (commandsv3/…/command3/Scheduler.java run()/schedule()/addPeriodic(), Command.java priorities, Mechanism.java default commands, BindingScope.java)