The Command Scheduler
The Engine Underneath Everything
The CommandScheduler is the singleton object that actually runs commands — everything in Lessons 25.1-25.2 depends on it silently doing its job every single loop. It must be told to run explicitly: CommandScheduler.getInstance().run() needs to be called from robotPeriodic() (Lesson 25.7 shows exactly where), or nothing in the whole command-based framework ever executes.
One Scheduler Iteration, Step by Step
Every 20ms (WPILib's default loop rate), the scheduler works through four steps, always in this order:
Step 1 — Run every registered Subsystem's periodic(). This happens first, so subsystems can update their own state (like odometry) before any commands act on it this loop.
Step 2 — Poll triggers for new commands to schedule. Every registered Trigger (Lesson 25.6) is checked; if a binding's condition is met, its command is scheduled and that command's initialize() runs immediately.
Step 3 — Run every currently-scheduled command. For each one: call execute(), then check isFinished(). If it's true, call end(false) and remove the command, freeing its subsystem requirements for anything else.
Step 4 — Schedule default commands. Any subsystem not currently claimed by another command gets its default command scheduled (if it has one) — this is how a drivetrain "just keeps driving from joystick input" whenever nothing more specific is happening.
How Requirement Conflicts Get Resolved
When a new command is scheduled and it needs a subsystem some other running command already has, the scheduler checks each conflicting command's interruption behavior (Lesson 25.2 briefly touched requirements; this is what happens when they collide): by default (kCancelSelf), the already-running command is canceled — its end(true) runs — and the new command takes over. A command can instead be marked kCancelIncoming, in which case the new command simply fails to schedule, and the original keeps running.
Why the Call Order Matters
Subsystem periodic() methods run before triggers are polled and commands execute, each loop — so a subsystem's periodic logic (like updating odometry) is always based on the previous loop's final state, and any command reading that subsystem's state this loop sees it freshly updated. This ordering is part of why command-based code behaves predictably: the sequence is the same, every single loop, without exception.
Common Pitfalls
- Forgetting to call
CommandScheduler.getInstance().run()inrobotPeriodic(). Without this one line, nothing in the entire command-based framework does anything — commands never execute, triggers never fire. - Assuming a command's
initialize()runs exactly when the scheduler'srun()is called. It actually runs the moment the command is scheduled — which for a trigger-bound command happens during Step 2 of whichever loop the trigger condition first becomes true. - Expecting a canceled command's
end()to reportinterrupted = false. A command that's forcibly interrupted (by a conflicting command, or an explicit cancel) always getsend(true)— only a command that finishes on its own viaisFinished()getsend(false).
Key Takeaways
- The
CommandScheduleris a singleton that must be run explicitly (CommandScheduler.getInstance().run(), fromrobotPeriodic()) — without this call, the framework does nothing. - Each iteration runs in a fixed order: subsystem
periodic()methods, then poll triggers for new commands, then execute/finish already-scheduled commands, then schedule any subsystem's default command if it's free. - Requirement conflicts are resolved by interruption behavior: the default cancels the already-running command in favor of the new one, unless that behavior is set to
kCancelIncoming. - This fixed, predictable run order is a big part of why command-based code behaves consistently loop after loop.
Derived from other-reference-repo: wpilib/command-based/08-command-scheduler.md (WPILIB 20.8)