498
Control Lab FRC Programming Curriculum
Robot Code · L06 of 12

Commands

Prereqs: robot-code-05
Objectives 0 / 5

Hook

You’ve wired up a subsystem with motors and sensors. Now you need the arm to move to 90 degrees when a button is pressed, hold there while the button is held, and return to zero when it’s released. How do you express that in code?

Not with a flag variable and a switch statement hidden in robotPeriodic(). That approach works for one button — it becomes unmanageable for five. Commands give you a clean, composable way to describe actions with explicit start, run, and stop behavior. They’re the reason teams can run 15-step autonomous routines written by three different people without catastrophic conflicts.

Core concept

Key Concept

A command is a Java object that encapsulates one robot action. It has four lifecycle methods: initialize() runs once when the command starts, execute() runs every loop iteration while it’s active, isFinished() tells the scheduler when to stop it, and end(boolean interrupted) runs once for cleanup. The CommandScheduler drives this lifecycle automatically.

The command lifecycle

           schedule()


        ┌─────────────┐
        │ initialize() │  ← runs once
        └──────┬──────┘


        ┌─────────────┐
        │  execute()   │  ← runs every 20 ms
        └──────┬──────┘

        isFinished()?
          No  │   Yes
          │   │
          │   ▼
          │  ┌─────────────────────┐
          │  │ end(interrupted=false)│  ← normal completion
          │  └─────────────────────┘

          └── (loops back to execute())

  If another command interrupts:
        ┌─────────────────────┐
        │ end(interrupted=true) │  ← cleanup, don't assume finished
        └─────────────────────┘
MethodWhen it runsTypical use
initialize()Once, at command startReset encoder, set initial setpoint, start timer
execute()Every loop, while activeApply motor output, calculate PID, update state
isFinished()Every loop, after execute()Check if goal reached, timer expired, etc.
end(boolean interrupted)Once, when doneStop motors, retract cylinder, log completion

The interrupted parameter in end() is true if another command cancelled this one, false if isFinished() returned true. This lets you clean up differently depending on whether you completed successfully.

Extending CommandBase

import edu.wpi.first.wpilibj2.command.CommandBase;
import edu.wpi.first.wpilibj.Timer;

public class DriveForDistance extends CommandBase {

    private final DriveSubsystem drive;
    private final double targetInches;
    private static final double TOLERANCE_INCHES = 1.0;

    public DriveForDistance(DriveSubsystem drive, double targetInches) {
        this.drive = drive;
        this.targetInches = targetInches;
        addRequirements(drive); // MUST include this
    }

    @Override
    public void initialize() {
        drive.resetEncoders(); // start measuring from zero
    }

    @Override
    public void execute() {
        double remaining = targetInches - drive.getAverageDistanceInches();
        double speed = remaining > 0 ? 0.5 : -0.5; // simple bang-bang
        drive.tankDrive(speed, speed);
    }

    @Override
    public boolean isFinished() {
        return Math.abs(targetInches - drive.getAverageDistanceInches()) < TOLERANCE_INCHES;
    }

    @Override
    public void end(boolean interrupted) {
        drive.tankDrive(0, 0); // always stop motors on exit
        if (interrupted) {
            System.out.println("DriveForDistance interrupted at "
                + drive.getAverageDistanceInches() + " inches");
        }
    }
}

The most common beginner mistake: forgetting addRequirements(). Without it, two commands can both call drive.tankDrive() simultaneously, and neither the scheduler nor you will know who “won.”

⚠ Heads up

If you forget addRequirements(), the scheduler cannot enforce exclusive ownership. Two commands will happily run at the same time against the same subsystem. The hardware output will be the result of whichever command ran last in the scheduler loop — unpredictable and impossible to debug under pressure.

Built-in command factories

Writing a full class for every action is verbose for simple cases. WPILib provides factory classes:

InstantCommand

Runs initialize() once, then immediately finishes. Perfect for toggling state.

// As an anonymous class (old style):
new InstantCommand(() -> claw.closeClaw(), claw);

// In a button binding:
new JoystickButton(controller, XboxController.Button.kA.value)
    .onTrue(new InstantCommand(() -> intake.deployArm(), intake));

RunCommand

Runs execute() every loop and never finishes on its own. Requires manual cancellation (e.g., the button is released).

// Drive while button is held:
new JoystickButton(controller, XboxController.Button.kRightBumper.value)
    .whileTrue(new RunCommand(
        () -> driveSubsystem.arcadeDrive(
            controller.getLeftY(),
            controller.getRightX()
        ),
        driveSubsystem
    ));

FunctionalCommand

The full lifecycle in a single call — useful when you need initialize/execute/end/isFinished but don’t want a separate file:

new FunctionalCommand(
    () -> drive.resetEncoders(),                              // initialize
    () -> drive.tankDrive(0.5, 0.5),                         // execute
    interrupted -> drive.tankDrive(0, 0),                    // end
    () -> drive.getAverageDistanceInches() >= 60.0,          // isFinished
    drive                                                    // requirements
);

Use FunctionalCommand sparingly — once the logic gets complex, a named class is more readable.

Command composition

The power of commands comes from composing them. WPILib provides two primary group types:

SequentialCommandGroup

Runs commands one after another. The next command starts only after the previous one finishes.

import edu.wpi.first.wpilibj2.command.SequentialCommandGroup;

public class ScoreAuto extends SequentialCommandGroup {
    public ScoreAuto(DriveSubsystem drive, ArmSubsystem arm, ClawSubsystem claw) {
        addCommands(
            new DriveForDistance(drive, 72),          // drive 72 inches forward
            new ArmToAngle(arm, 90),                  // raise arm to 90 degrees
            new InstantCommand(claw::closeClaw, claw), // grab game piece
            new ArmToAngle(arm, 0),                   // lower arm
            new DriveForDistance(drive, -72)           // back up
        );
    }
}

ParallelCommandGroup

Runs commands simultaneously. The group finishes when all commands finish.

import edu.wpi.first.wpilibj2.command.ParallelCommandGroup;

// Raise arm AND spin up shooter at the same time
new ParallelCommandGroup(
    new ArmToAngle(arm, 75),
    new SpinUpShooter(shooter, 4500) // RPM
)

ParallelRaceGroup

Runs commands simultaneously. Finishes when any one command finishes, then cancels the rest.

import edu.wpi.first.wpilibj2.command.ParallelRaceGroup;

// Intake until a ball is detected, then stop — timeout after 3 seconds
new ParallelRaceGroup(
    new RunIntake(intake),           // never finishes on its own
    new WaitUntilCommand(intake::hasBall), // finishes when sensor triggers
    new WaitCommand(3.0)             // safety timeout
)

ParallelDeadlineGroup

Like ParallelRaceGroup but one specific command is the “deadline” — the group ends when that one finishes.

import edu.wpi.first.wpilibj2.command.ParallelDeadlineGroup;

// Drive while also tracking with the limelight — stop when drive finishes
new ParallelDeadlineGroup(
    new DriveForDistance(drive, 48), // deadline
    new TrackTarget(vision)          // runs alongside, cancelled when drive ends
)

Inline composition with decorators

Commands also expose chainable decorator methods:

new ArmToAngle(arm, 90)
    .withTimeout(3.0)                 // cancel after 3 seconds if not finished
    .andThen(new InstantCommand(claw::closeClaw, claw))  // run next in sequence
    .beforeStarting(() -> System.out.println("Starting arm sequence"))
    .finallyDo(interrupted -> arm.setSpeed(0));
Note

Decorator methods return a new command wrapping the original — they don’t modify in place. Chain them like you would Java stream operations: each call returns the decorated version.

Complete example: coordinated autonomous sequence

import edu.wpi.first.wpilibj2.command.SequentialCommandGroup;
import edu.wpi.first.wpilibj2.command.ParallelCommandGroup;
import edu.wpi.first.wpilibj2.command.InstantCommand;
import edu.wpi.first.wpilibj2.command.WaitCommand;

public class TwoGamePieceAuto extends SequentialCommandGroup {

    public TwoGamePieceAuto(
        DriveSubsystem drive,
        ArmSubsystem arm,
        ClawSubsystem claw
    ) {
        addCommands(
            // 1. Start: arm up + drive forward simultaneously
            new ParallelCommandGroup(
                new ArmToAngle(arm, 90),
                new DriveForDistance(drive, 24)
            ),

            // 2. Grab first game piece
            new InstantCommand(claw::closeClaw, claw),
            new WaitCommand(0.3), // let cylinder fully extend

            // 3. Back up and lower arm
            new ParallelCommandGroup(
                new DriveForDistance(drive, -24),
                new ArmToAngle(arm, 0)
            ),

            // 4. Score position
            new DriveForDistance(drive, 60),
            new ArmToAngle(arm, 45),
            new InstantCommand(claw::openClaw, claw),
            new WaitCommand(0.2),

            // 5. Return to start
            new ParallelCommandGroup(
                new DriveForDistance(drive, -60),
                new ArmToAngle(arm, 0)
            )
        );
    }
}

Full lifecycle trace

Code Tracer
01new ArmToAngle(arm, 90).schedule()// placed in scheduler queue
02initialize()// runs once — set up PID setpoint
03execute() — loop 1// PID output applied, arm moving
04execute() — loop 2// still moving
05execute() — loop 15// approaching target
06isFinished() returns true (|90 - 89.1| < 2.0)// within tolerance
07end(interrupted=false)// normal completion — stop motor
08command removed from scheduler// subsystem free — default command resumes
State
commandnone
phaseidle
armDeg0.0
finishedfalse
interruptedfalse

Step through to see values update.

Initial state

Interrupted lifecycle trace

Code Tracer
01DriveForDistance(60) running// driving forward normally
02New command EmergencyStop(drive) scheduled// new command requires Drive — conflict detected
03end(interrupted=true) on DriveForDistance// old command cleaned up, new one starts
04EmergencyStop.execute() — sets all motors to 0// robot stopped safely
State
commandDriveForDistance(60in)
phaseexecute
distIn12.0
interruptedfalse

Step through to see values update.

Initial state

Key takeaways

  • Commands have four lifecycle methods: initialize(), execute(), isFinished(), end(interrupted).
  • initialize() and end() each run exactly once. execute() and isFinished() run every loop while active.
  • Always call addRequirements() — it’s what gives the scheduler the information it needs to prevent conflicts.
  • InstantCommand, RunCommand, and FunctionalCommand cover simple cases without full class boilerplate.
  • Sequential and parallel groups compose simple commands into complex behaviors declaratively.
  • end(true) signals interruption — clean up safely but don’t assume the goal was reached.

Common confusions

“My command runs once and stops immediately.” isFinished() is probably returning true before execute() has done anything meaningful. This often happens when a state variable isn’t initialized in initialize() (e.g., forgetting drive.resetEncoders() before checking distance traveled).

“My sequential group skips commands.” A command in the group’s isFinished() may be returning true immediately (see above). Add a print statement in initialize() and end() of each command to trace which ones actually ran.

“My InstantCommand isn’t doing anything.” Lambda captures in Java don’t evaluate at call time — they evaluate at execution time. But if the object you’re calling (e.g., intake) was null when the lambda was created, you’ll get a NullPointerException silently if the scheduler swallows exceptions. Always check that your subsystem references are non-null.

“I added withTimeout() but the command still runs forever.” withTimeout(3.0) wraps the command in a ParallelRaceGroup with a WaitCommand(3.0). It works correctly for commands that are scheduled directly. If the command is inside a SequentialCommandGroup, the group’s command is the wrapper — confirm you’re calling withTimeout() on the command object you’re adding to the group.

Challenge

⚡ Try it yourself

Trace through a simplified command lifecycle in pure Java. Implement a SimulatedCommand class with initialize(), execute(), isFinished(), and end(boolean) methods, then write a simple scheduler loop that drives the lifecycle correctly. The command should run for exactly 5 execute() calls before finishing.

Code EditorJavaCtrl+Enter to run
Stuck? Show hint

In runCommand(): call command.initialize(). Then use a while loop: call command.execute(), then check command.isFinished(). If true, call command.end(false) and break. Add a counter for the safety limit — if it exceeds 20, call command.end(true) and break.

⚡ Check your understanding

A command's isFinished() returns true on the very first call (before execute() runs). What happens?

⚡ Check your understanding

You build a SequentialCommandGroup with three commands. The second command requires the same subsystem as the third. What happens when the second command finishes and the third starts?

What’s next

You now have the complete command-based framework: subsystems own hardware, commands describe actions with a clean lifecycle, and the scheduler orchestrates everything. The next lessons will put these together with feedback control — you’ll implement a PID controller that uses encoder feedback to drive a mechanism to a precise position, and learn how WPILib’s PIDController and ProfiledPIDController make this composable with the command system.