Project 3 — Refactor into a Command-Based Drive Subsystem
Convert the flat arcade-drive program into a proper Subsystem + default command using the 2026 command-based framework.
Sign in to track progress, earn XP, and save lessons.
The flat Robot.java works, but it does not scale. The moment you add a second mechanism you will be fighting tangled state. WPILib strongly recommends command-based for new teams. Let us refactor.
A command-based project has four root files: Robot (mostly empty), RobotContainer (declares subsystems + bindings), Constants, and Main (do not touch). Subsystems live in subsystems/, commands in commands/.
DriveSubsystem.java:
package frc.robot.subsystems;
import com.revrobotics.spark.SparkMax;
import com.revrobotics.spark.SparkLowLevel.MotorType;
import edu.wpi.first.wpilibj.drive.DifferentialDrive;
import edu.wpi.first.wpilibj2.command.SubsystemBase;
public class DriveSubsystem extends SubsystemBase {
private final SparkMax m_left = new SparkMax(1, MotorType.kBrushless);
private final SparkMax m_right = new SparkMax(2, MotorType.kBrushless);
private final DifferentialDrive m_drive =
new DifferentialDrive(m_left::set, m_right::set);
public void arcade(double fwd, double rot) {
m_drive.arcadeDrive(fwd, rot);
}
}
Note the CAN IDs 1 and 2 match the IDs you persisted in Project 1. We now use the CAN SparkMax class (from the REVLib 2025+ com.revrobotics.spark package) instead of PWM, and pass its ::set method to DifferentialDrive.
RobotContainer.java wires a default command so the drivetrain is always listening to the sticks:
public class RobotContainer {
private final DriveSubsystem m_drive = new DriveSubsystem();
private final CommandXboxController m_ctrl =
new CommandXboxController(0);
public RobotContainer() { configureBindings(); }
private void configureBindings() {
m_drive.setDefaultCommand(
m_drive.run(() ->
m_drive.arcade(-m_ctrl.getLeftY(), -m_ctrl.getRightX())));
// hold B for a quick 'spin in place' demo
m_ctrl.b().whileTrue(m_drive.run(() -> m_drive.arcade(0, 0.5)));
}
}
Robot.java just runs the scheduler:
@Override public void robotPeriodic() {
CommandScheduler.getInstance().run();
}
Why this matters: the CommandScheduler runs each loop (the default TimedRobot period is 20ms / 50Hz) and, each loop, polls triggers, schedules new commands, runs scheduled commands, removes finished ones, then runs subsystem periodic() methods. The single most common command-based bug is forgetting CommandScheduler.getInstance().run() in robotPeriodic() — without it, nothing happens and the robot looks dead even though code is deployed. The setDefaultCommand pattern guarantees the drivetrain always has something to do when no other command requires it.
Key takeaways
- Command-based splits code into Subsystems, Commands, and bindings in RobotContainer — it scales where flat TimedRobot does not
- Forgetting CommandScheduler.getInstance().run() in robotPeriodic() makes the whole robot look dead
- setDefaultCommand keeps a subsystem (like drive) responsive whenever no other command requires it
Go deeper
Lesson quiz
RequiredAnswer all 3 questions correctly to complete this lesson.
1.When refactoring a drivetrain into a command-based subsystem, what is the recommended base class to extend?
2.What is the purpose of setting a default command on the drive subsystem?
3.For a teleop drive default command, what should its isFinished() method return?
Answer every question to submit.