This document chronicles the refactoring of lebot into lebot2, applying the principles from our Coding Guidelines. It serves both as a learning resource and a reference for future refactoring projects.
Table of Contents
- The Robot: Lebot
- Problems with the Original Code
- The Refactoring Plan
- Implementation Journal
- Before and After Comparisons
- Lessons Learned
The Robot: Lebot
Lebot is a ball-launching robot designed for a game where robots collect balls and launch them at targets. Understanding the physical design is essential before discussing code architecture.
Physical Components
┌─────────────────────────────────────────────────────────────┐
│ ROBOT TOP VIEW │
│ │
│ [Left Motor] [Right Motor] │
│ ○ ○ │
│ [Flywheel] ←── Launches balls at calculated speed │
│ ↑ │
│ [Paddle] ←── Lifts ball into flywheel (also gate) │
│ ↑ │
│ [Back Sensor] ←── Detects ball ready to launch │
│ ↑ │
│ ┌─────────┐ │
│ │ LOADER │ ←── Side belt moves balls toward rear │
│ │ CHAMBER │ (holds up to 3 balls) │
│ └─────────┘ │
│ ↑ │
│ [Front Sensor] ←── Detects incoming balls │
│ ↑ │
│ [Intake] ←── Overhead belts pull balls in │
│ ↑ │
│ ══════════════ (funnel opening) │
│ │
│ [Omni Casters] │
│ ○ ○ │
└─────────────────────────────────────────────────────────────┘
Ball Flow Path
- Intake: Overhead belts at the front pull balls into the robot
- Funnel: Guides balls from wide intake into single-file chamber
- Loader Chamber: Side belt moves balls toward the rear; holds max 3 balls
- Paddle Gate: When down, stops balls at rear; when up, allows launch
- Flywheel: Spinning wheel that launches balls when paddle lifts them into contact
Hardware Inventory
| Component | Type | Purpose | |
|---|---|---|---|
| leftFront, rightFront | DcMotorEx | Differential drive | |
| intake | DcMotorEx | Overhead belt motor | |
| conveyor | DcMotorEx | Side belt motor (loader) | |
| launcher | DcMotorEx | Flywheel motor | likely to add a 2nd motor |
| paddle | Servo | Lifter/gate mechanism | |
| frontDist | Rev2mDistanceSensor | Detects balls entering | |
| backDist | Rev2mDistanceSensor | Detects balls ready to launch | |
| limelight | Limelight3A | Vision targeting | |
| imu | BNO055IMU | Heading for turns |
Operating Modes
The robot operates in distinct modes during a match:
- INTAKE - Actively gathering balls
- Overhead belts ON, side belt ON
- Paddle DOWN (gate closed)
- TRANSIT - Driving to launch position
- All ball handling OFF
- Driver controls active
- TARGETING - Aligning with target
- IMU-based rough turn
- Limelight fine adjustment
- LAUNCHING - Firing balls
- Flywheel spins to calculated speed
- Paddle lifts ball into flywheel
- Side belt feeds next ball
- Repeat until empty
Key Constraints
- Max 3 balls: Game rule AND physical limit
- Flywheel must be at speed: Ball contact before speed = jam
- Speed depends on distance: Calculated from Limelight measurements
- Dual-role components:
- Side belt: intake helper AND launch feeder
- Paddle: launch trigger AND loader gate
Problems with the Original Code
The original lebot/Robot.java (746 lines) exhibits several anti-patterns identified in our Coding Guidelines.
1. God Class Anti-Pattern
The Robot class handles everything:
- Drivetrain control
- Intake motors
- Flywheel and paddle
- Limelight vision
- Distance sensors
- Ball counting
- Multiple state machines
Guideline Violated: “Break the robot into subsystems - independent units that each handle one responsibility.”
2. Integer-Based State Machines
// Original code
public int index = 0;
public void shootSequence() {
switch (index) {
case 0: // what does 0 mean?
index++;
break;
case 1: // and 1?
// ...
}
}
Problems:
- Magic numbers (what is case 4?)
- Hard to read and debug
- Easy to make off-by-one errors
Guideline Violated: “Use enums for state machine states.”
3. Duplicate Hardware Initialization
IMU initialized in both Robot.java AND tankDrive.java:
// In Robot.java
imu = hardwareMap.get(BNO055IMU.class, "imu");
// In tankDrive.java
imu = hardwareMap.get(BNO055IMU.class, "imu");
Guideline Violated: “Each piece of hardware should be owned by exactly one subsystem.”
4. Mixed Sensor Reads and Logic
// Original - reads scattered throughout
public void update(Canvas fieldOverlay) {
LLResult llResult = limelight.getLatestResult(); // read
if (llResult != null && llResult.isValid()) { // logic
botpose = llResult.getBotpose_MT2(); // more logic
}
updateDistance(); // another read
if (shootingAll) {
shootALLSequence(); // logic using reads
}
// ...
}
Guideline Violated: “Follow the Read→Process→Write pattern.”
5. Direct Dashboard Calls in Telemetry
// Original - side effect in getTelemetry
public Map<String, Object> getTelemetry(boolean debug) {
// ... build map ...
dashboard.sendTelemetryPacket(p); // WRONG! Side effect!
return telemetry2;
}
Guideline Violated: “Telemetry should be collected, not transmitted, by subsystems.”
The Refactoring Plan
New Architecture
lebot2/
├── Robot.java # Coordinator, complex behaviors
├── DriverControls.java # Gamepad handling
├── Lebot2_6832.java # Unified OpMode (Auton+TeleOp)
├── subsystem/
│ ├── Subsystem.java # Interface
│ ├── drivetrain/
│ │ ├── DriveTrainBase.java # Interface for swappable drivetrains
│ │ └── TankDrive.java # Differential drive implementation
│ ├── Intake.java # Overhead belts only
│ ├── Loader.java # Side belt + sensors + ball counting
│ ├── Launcher.java # Flywheel + paddle + launch sequence
│ └── Vision.java # Limelight
└── util/
└── (shared utilities)
Subsystem Ownership
| Subsystem | Hardware Owned | Primary Responsibility |
|---|---|---|
| TankDrive | leftFront, rightFront, IMU, Pinpoint | Locomotion, heading control |
| Intake | intake motor | Pull balls into robot |
| Loader | conveyor motor, frontDist, backDist | Ball counting, feeding |
| Launcher | shooter motor, paddle servo | Speed calc, launch sequence |
| Vision | limelight | Target detection |
Design Decisions
- Swappable Drivetrain: Interface pattern allows TankDrive, MecanumDrive, SwerveDrive
- Loader owns belt: Side belt’s primary purpose is ball management
- Launcher owns paddle: Paddle’s critical function is launch triggering
- Cross-subsystem coordination: Robot class orchestrates multi-subsystem behaviors
Implementation Journal
Step 1: Directory Structure and Interfaces
Completed: January 2026
Created the lebot2 directory structure and copied the Subsystem interface pattern from deepthought.
Files created:
lebot2/subsystem/Subsystem.java- Base interface extending TelemetryProviderlebot2/util/TelemetryProvider.java- Telemetry collection interface
The Subsystem interface:
public interface Subsystem extends TelemetryProvider {
void update(Canvas fieldOverlay);
void stop();
void resetStates();
}
Step 2: DriveTrainBase Interface and TankDrive
Completed: January 2026
Created swappable drivetrain architecture.
Key decisions:
- Interface allows future chassis types (MecanumDrive, SwerveDrive)
- TankDrive ignores strafe parameter (differential drive limitation)
- Heading control via PID for autonomous turns
Files created:
lebot2/subsystem/drivetrain/DriveTrainBase.javalebot2/subsystem/drivetrain/TankDrive.java
DriveTrainBase interface:
public interface DriveTrainBase extends Subsystem {
void drive(double throttle, double strafe, double turn);
void setHeadingTarget(double degrees);
void setLimelightTarget(double tx);
boolean isTurning();
double getHeading();
Pose2d getPose();
void setPose(Pose2d pose);
}
Step 3: Simple Subsystems (Intake)
Completed: January 2026
The Intake subsystem is the simplest - just overhead belts with on/off control.
Files created:
lebot2/subsystem/Intake.java
Key pattern: Simple subsystems don’t need state machines. Just provide control methods and let the coordinator decide when to use them.
Step 4: Loader Subsystem (Belt + Sensors + Ball Counting)
Completed: January 2026
The Loader manages ball flow through the robot with two distance sensors.
Files created:
lebot2/subsystem/Loader.java
Ball counting logic:
public enum LoaderState {
EMPTY,
HAS_BALLS,
FULL
}
// Front sensor: ball entering
// Back sensor: ball ready to launch
// Rising edge detection for accurate counting
Virtual sensors: The Loader exposes virtual sensors - computed values derived from physical sensors that abstract implementation details from behaviors:
| Virtual Sensor | Physical Sources | Purpose |
|---|---|---|
isFull() |
ballCount + backDist | Time-confirmed “chamber is full” |
isBallAtBack() |
backDist sensor | Ball ready to launch |
isBallAtFront() |
frontDist sensor | Ball just entered |
The isFull() method uses time-confirmed debouncing to prevent flickering when balls are at sensor thresholds:
// Compute time-confirmed isFull virtual sensor
boolean rawFull = (state == LoaderState.FULL) && ballAtBack;
if (rawFull) {
if (fullConditionStartMs == 0) {
fullConditionStartMs = System.currentTimeMillis();
}
isFullConfirmed = (System.currentTimeMillis() - fullConditionStartMs) >= FULL_CONFIRM_MS;
} else {
fullConditionStartMs = 0;
isFullConfirmed = false;
}
This pattern allows behaviors to simply call loader.isFull() without knowing about sensor thresholds, debounce timing, or the combination of count + sensor confirmation. See Virtual Sensors in Coding Guidelines for more details.
Sensor ownership decision: Loader owns both distance sensors because their primary purpose is ball management, even though the back sensor is read during launch.
Step 5: Launcher Subsystem (Flywheel + Paddle)
Completed: January 2026
The most complex subsystem with a proper enum-based state machine.
Files created:
lebot2/subsystem/Launcher.java
Launch state machine:
public enum LaunchState {
IDLE, // Waiting for command
SPINNING_UP, // Flywheel ramping to target speed
READY, // At speed, waiting for fire command
FIRING, // Paddle up, ball launching
COOLDOWN // Reset before next ball
}
Distance-based speed calculation: The flywheel speed is calculated from target distance (via Limelight) to ensure consistent arc trajectories.
Step 6: Vision Subsystem
Completed: January 2026
Wraps Limelight3A for target detection.
Files created:
lebot2/subsystem/Vision.java
Features:
- Pipeline switching for alliance colors
- Target detection with tx/ty values
- Distance calculation from ty angle
Step 7: Robot Coordinator
Completed: January 2026
The Robot class orchestrates multi-subsystem behaviors through articulations.
Files created:
lebot2/Robot.java
Articulation enum:
public enum Articulation {
MANUAL, // Direct driver control
INTAKE, // Gathering balls
TRANSIT, // Driving to position
TARGETING, // Aligning with target
LAUNCHING, // Single ball launch
LAUNCH_ALL, // Multi-ball sequence
RELEASE // Emergency release
}
Cross-subsystem coordination example:
case LAUNCHING:
if (launcher.getState() == Launcher.LaunchState.READY) {
launcher.fire();
}
if (launcher.getState() == Launcher.LaunchState.COOLDOWN) {
loader.feedForward();
articulation = Articulation.MANUAL;
}
break;
Step 8: DriverControls and Unified OpMode
Completed: January 2026
Files created:
lebot2/DriverControls.java- Gamepad input handlinglebot2/Lebot2_6832.java- Unified OpMode (Auton+TeleOp)
Unified OpMode pattern (from deepthought):
public enum GameState {
AUTONOMOUS,
TELE_OP,
TEST,
DEMO
}
// Same initialization for all modes
// GameState selected during init_loop
// No controller input during competition autonomous
Step 9: RoadRunner Integration
Completed: January 2026
Integrated RoadRunner v1.0 trajectory following with dual localization options.
Files created:
lebot2/rr_localize/TankDrive.java- Drive wheel encoder localizationlebot2/rr_localize/PinpointLocalizer.java- Pinpoint odometry wrapperlebot2/rr_localize/TankDrivePinpoint.java- Pinpoint-based localizationlebot2/rr_localize/Localizer.java(interface from rrQuickStart)lebot2/rr_localize/tuning/TuningOpModes.java- Tuning utilities
Dual localization approach:
| Class | Localization | Use Case |
|——-|————–|———-|
| TankDrive | Drive wheel encoders | Testing, comparison baseline |
| TankDrivePinpoint | Pinpoint odometry pods | Competition (more accurate) |
Why two options? Drive wheel encoders can slip, especially on mecanum or when pushing. Dedicated odometry pods provide more reliable position tracking.
Motor configuration (for lebot2 chassis):
// Differential drive with rear-mounted motors
leftMotors = Arrays.asList(hardwareMap.get(DcMotorEx.class, "leftRear"));
rightMotors = Arrays.asList(hardwareMap.get(DcMotorEx.class, "rightRear"));
// Left motor reversed so positive power = forward
leftMotors.get(0).setDirection(DcMotorSimple.Direction.REVERSE);
Step 10: SDK Compatibility Fixes
Completed: January 2026
Updated imports for GoBildaPinpointDriver after SDK update.
The issue: The 2024-2025 SDK moved GoBildaPinpointDriver from org.firstinspires.ftc.teamcode to com.qualcomm.hardware.gobilda.
Files fixed (6 total across robots):
lebot2/rr_localize/tuning/TuningOpModes.javadeepthought/rr_localize/tuning/TuningOpModes.javaswervolicious/rr_localize/tuning/TuningOpModes.javalebot/PinpointLocalizer.javaswervolicious/rr_localize/PinpointLocalizer.javadeepthought/rr_localize/PinpointLocalizer.java
Old import:
import org.firstinspires.ftc.teamcode.GoBildaPinpointDriver;
New import:
import com.qualcomm.hardware.gobilda.GoBildaPinpointDriver;
Lesson: When SDK versions change, check for moved/renamed classes in the release notes.
Step 11: Three-Phase Update Pattern
Completed: January 2026
Added a strict three-phase update cycle to eliminate sensor read ordering bugs and enforce clean separation between I2C reads, calculations, and hardware writes.
Problem identified: Student coders could accidentally read stale sensor data or trigger I2C reads in the wrong order. The original update() method mixed sensor reads, logic, and motor writes unpredictably.
Solution: Split the subsystem interface into three explicit phases:
public interface Subsystem extends TelemetryProvider {
void readSensors(); // Phase 1: I2C reads only
void calc(Canvas fieldOverlay); // Phase 2: Logic using cached values
void act(); // Phase 3: Flush motor/servo commands
void stop();
void resetStates();
}
New wrapper classes created (lebot2/util/):
| Class | Purpose |
|---|---|
LazyMotor |
Defers motor power writes until Phase 3 |
LazyServo |
Defers servo position writes until Phase 3 |
CachedDistanceSensor |
Caches I2C distance sensor reads from Phase 1 |
CachedMotorCurrent |
Read-only motor current monitoring for stall detection |
Robot.java update loop:
public void update(Canvas fieldOverlay) {
// Phase 1: All sensor reads
for (LynxModule hub : hubs) {
hub.clearBulkCache();
}
for (Subsystem s : subsystems) {
s.readSensors();
}
// Phase 2: All calculations
// Articulation state machine runs here
for (Subsystem s : subsystems) {
s.calc(fieldOverlay);
}
// Phase 3: All writes
for (Subsystem s : subsystems) {
s.act();
}
}
MANUAL bulk caching: Changed to LynxModule.BulkCachingMode.MANUAL with explicit clearBulkCache() at the start of each cycle for maximum performance.
Files modified:
lebot2/subsystem/Subsystem.java- Three-phase interfacelebot2/Robot.java- MANUAL bulk caching, three-phase looplebot2/subsystem/Intake.java- Uses LazyMotorlebot2/subsystem/Loader.java- Uses LazyMotor + CachedDistanceSensorlebot2/subsystem/Launcher.java- Uses LazyServolebot2/subsystem/drivetrain/TankDrive.java- Caches IMU headinglebot2/subsystem/Vision.java- Minimal changes (Limelight is USB, not I2C)
Key insight: Drive motors are NOT wrapped in LazyMotor because RoadRunner needs direct access for trajectory following. The three-phase pattern applies to subsystem-owned hardware only.
Documentation updated:
Coding_Guidelines.md- Added “Three-Phase Update Pattern” section with rules for student coders
Step 12: TankDrivePinpoint Subsystem Integration
Completed: January 2026
Refactored TankDrivePinpoint to implement DriveTrainBase, unifying RoadRunner trajectory following with the Subsystem three-phase pattern.
Problem identified: The codebase had two parallel drivetrain implementations:
subsystem/drivetrain/TankDrive- Simple drivetrain implementing Subsystem, no RoadRunnerrr_localize/TankDrivePinpoint- RoadRunner drivetrain with Pinpoint, no Subsystem integration
Robot.java was using the simple TankDrive, missing out on RoadRunner’s trajectory capabilities and Pinpoint’s superior odometry.
Solution: Refactor TankDrivePinpoint to implement DriveTrainBase (which extends Subsystem):
public final class TankDrivePinpoint implements DriveTrainBase {
// RoadRunner infrastructure (Actions, kinematics, PARAMS)
// + Subsystem three-phase methods
// + Teleop driving and PID turns
}
Dual-purpose design:
| Use Case | How It Works |
|---|---|
| Subsystem in Robot.java | readSensors() refreshes Pinpoint, calc() updates pose and runs turn PID |
| RoadRunner trajectories | FollowTrajectoryAction and TurnAction write motors directly |
| TuningOpModes | Access public fields (leftMotors, rightMotors, localizer, PARAMS) |
| Teleop driving | drive(throttle, strafe, turn) method |
Key changes:
- Added
readSensors(): CallsPinpointLocalizer.refresh()for I2C bulk read - Added
calc(): Updates pose estimate, runs turn state machine, draws on field overlay - Added
act(): No-op (RoadRunner Actions write motors directly) - Added
turnToHeading(),turnToTarget(),isTurnComplete(): PID-based turns using Pinpoint heading - Added
drive(): Teleop driving (ignores strafe for tank drive) - Removed bulk caching setup from constructor (Robot.java handles it)
- Kept all RoadRunner infrastructure intact
Files modified:
lebot2/rr_localize/TankDrivePinpoint.java- Now implements DriveTrainBaselebot2/rr_localize/TankDrive.java- Also implements DriveTrainBase (encoder fallback)lebot2/Robot.java- Uses TankDrivePinpoint instead of subsystem TankDrivelebot2/rr_localize/tuning/TuningOpModes.java- Added TankDrivePinpoint case
Drivetrain hierarchy (in order of preference):
rr_localize/TankDrivePinpoint- RoadRunner + Pinpoint odometry (most accurate)rr_localize/TankDrive- RoadRunner + drive wheel encoders (fallback if Pinpoint fails)subsystem/drivetrain/TankDrive- Simple drivetrain, no RoadRunner (last resort)
Tuning compatibility preserved: TuningOpModes can still access all the public fields it needs:
TankDrivePinpoint td = new TankDrivePinpoint(hardwareMap, new Pose2d(0, 0, 0));
// Access td.leftMotors, td.rightMotors, td.localizer, TankDrivePinpoint.PARAMS
Fallback option: The original subsystem/drivetrain/TankDrive remains as a no-Pinpoint fallback if the odometry pod is damaged.
Step 13: Parallel Control Domain Architecture
Completed: January 2026
Refactored the robot from a monolithic articulation system to a parallel control domain architecture where each subsystem manages its own behaviors independently.
Problem identified: The original articulation system blocked drive during intake operations. A code review revealed:
- When Robot was in
INTAKEarticulation, drive was disabled - Intake and drive use completely different hardware - no reason they can’t run in parallel
- The monolithic articulation enum tried to handle too many combinations
- A double
drive()call bug overwrote throttle when turning
Resource Conflict Analysis
Before designing the solution, we systematically analyzed which hardware resources each subsystem uses:
| Hardware | Subsystem | Exclusive? |
|---|---|---|
| leftRear motor | DriveTrain | Yes |
| rightRear motor | DriveTrain | Yes |
| Pinpoint odometry | DriveTrain | Yes |
| IMU | DriveTrain | Yes |
| intake motor | Intake | Yes |
| belt motor | Loader | Shared |
| frontDist sensor | Loader | Yes |
| backDist sensor | Loader | Yes |
| flywheel motor | Launcher | Yes |
| paddle servo | Launcher | Yes |
| Limelight | Vision | Yes |
The only true conflict: Belt Motor
The belt is the only hardware shared between subsystems:
- Intake mode: Belt pulls balls toward rear
- Launch mode: Belt feeds balls to paddle (same direction)
Interestingly, they run the same direction - the conflict is about ownership and stopping logic, not direction. When launcher fires, it needs exclusive belt control to time ball feeding.
What can run in parallel?
| Combination | Can Run Together? | Reason |
|---|---|---|
| Drive + Intake | ✅ Yes | Different motors, no shared hardware |
| Drive + Launcher spin | ✅ Yes | Different motors, no shared hardware |
| Drive + Launcher firing | ✅ Yes | Unusual, but no hardware conflict |
| Intake + Launcher spin | ✅ Yes | Belt still owned by Intake |
| Intake + Launcher firing | ❌ No | Belt conflict - Launcher needs exclusive access |
Key insight: Most combinations CAN run in parallel. The monolithic articulation was overly restrictive.
Solution: Parallel control domains where each subsystem manages its own state machine:
┌─────────────────────────────────────────────────────────────┐
│ PARALLEL CONTROL DOMAINS │
├─────────────────────────────────────────────────────────────┤
│ DriveTrain │ MANUAL, TRAJECTORY, PID_TURN │
│ │ (interrupted by joystick input) │
├────────────────┼────────────────────────────────────────────┤
│ Intake │ OFF, INTAKE, LOAD_ALL, EJECT │
│ │ (auto-completes when loader full) │
├────────────────┼────────────────────────────────────────────┤
│ Loader │ Manages belt ownership (INTAKE > LAUNCHER) │
│ │ (shared resource arbitration) │
├────────────────┼────────────────────────────────────────────┤
│ Launcher │ IDLE, SPINNING (auto-pulls vision dist) │
│ │ (claims belt when firing) │
├────────────────┼────────────────────────────────────────────┤
│ Robot │ MANUAL, TARGETING, LAUNCH_ALL │
│ │ (multi-subsystem coordination only) │
└─────────────────────────────────────────────────────────────┘
Unified Behavior Pattern: All subsystems now use consistent API:
// Every subsystem has:
public enum Behavior { ... }
public void setBehavior(Behavior b) { ... }
public Behavior getBehavior() { ... }
// Examples:
robot.setBehavior(Robot.Behavior.LAUNCH_ALL);
launcher.setBehavior(Launcher.Behavior.SPINNING);
// DriveTrain sets behavior implicitly via drive(), runAction(), turnToHeading()
Belt Ownership Model: The Loader arbitrates belt access between competing subsystems:
// Priority: LAUNCHER > INTAKE > NONE
public enum BeltOwner { NONE, INTAKE, LAUNCHER }
// Intake requests (can be preempted)
loader.requestBeltForIntake();
loader.releaseBeltFromIntake();
// Launcher claims (highest priority)
loader.claimBeltForLauncher();
loader.releaseBeltFromLauncher();
// In calc(): resolve ownership and set belt power
if (launcherClaimsBelt) {
currentOwner = BeltOwner.LAUNCHER;
beltMotor.setPower(launcherBeltPower);
} else if (intakeRequestsBelt) {
currentOwner = BeltOwner.INTAKE;
beltMotor.setPower(INTAKE_FEED_POWER);
} else {
currentOwner = BeltOwner.NONE;
beltMotor.setPower(0);
}
Key architectural decisions:
| Decision | Rationale |
|---|---|
| Drive always available | Different hardware than intake/launcher, no reason to block |
| Joystick interrupts trajectories | Driver safety - always able to override automation |
| Launcher pulls distance from Vision | Subsystem autonomy - doesn’t need parameter passed |
| Intake suppressed during fire | Belt used exclusively by launcher when firing |
| No idle timeout for launcher | Driver decides when to stop spinning |
Files modified:
Intake.java- Added Behavior enum, loadAll() auto-stops when fullLoader.java- Added BeltOwner tracking, priority resolution in calc()Launcher.java- Added Behavior enum, auto-pulls distance from VisionTankDrivePinpoint.java- Renamed DriveMode to BehaviorRobot.java- Renamed Articulation to Behavior, simplified to multi-subsystem onlyDriverControls.java- Drive always called, uses new behavior APIsAutonomous.java- Updated to use new behavior APIs
Bug fixed: Double drive() call in joystickDrive() that overwrote throttle when turning.
Lessons learned:
- Subsystems using different hardware should operate independently
- Robot-level behaviors should only coordinate multi-subsystem sequences
- Shared resources need explicit ownership models (like belt claiming)
- Unified API patterns (
setBehavior()/getBehavior()) make code predictable
Before and After Comparisons
State Machine: Integer vs Enum
Before (lebot/Robot.java):
public int index = 0;
public void shootSequence() {
switch (index) {
case 0:
index++;
break;
case 1:
shooter.setVelocity(minShooterSpeed, AngleUnit.DEGREES);
index++;
break;
// ... cases 2, 3, 4, 5 ...
}
}
After (lebot2/subsystem/Launcher.java):
public enum LaunchState {
IDLE,
SPINNING_UP,
READY,
FIRING,
COOLDOWN
}
private LaunchState state = LaunchState.IDLE;
@Override
public void update(Canvas fieldOverlay) {
switch (state) {
case IDLE:
// Clear state, nothing to do
break;
case SPINNING_UP:
if (isFlywheelAtSpeed()) {
state = LaunchState.READY;
}
break;
// ... etc
}
}
Why it’s better:
- Self-documenting (SPINNING_UP vs case 1)
- IDE autocomplete support
- Compiler catches typos
- Easy to add/remove states
Lessons Learned
1. Hardware Ownership Matters
Deciding which subsystem owns dual-role components requires understanding the highest-priority use case.
Example: The paddle servo could belong to either Loader or Launcher:
- Loader perspective: It’s a gate that stops balls
- Launcher perspective: It’s the trigger that fires balls
Decision: Launcher owns it because the timing-critical function (coordinating with flywheel speed) is more important than the passive gate function.
2. State Machines Need Clear Entry/Exit Conditions
Each state transition should have documented:
- What condition triggers entry
- What actions happen on entry
- What condition triggers exit
Anti-pattern: States that transition “after a delay” without explaining why.
Good pattern:
case SPINNING_UP:
// Entry: prepareToLaunch() called with distance
// Exit: Flywheel reaches target velocity (±5%)
if (isFlywheelAtSpeed()) {
state = LaunchState.READY;
}
break;
3. Cross-Subsystem Coordination Belongs in Robot
A subsystem should never directly call methods on another subsystem. The Robot coordinator handles all multi-subsystem behaviors.
Why? If Launcher directly called loader.feedForward(), then:
- Launcher needs a reference to Loader
- Testing Launcher requires mocking Loader
- The dependency graph becomes tangled
Better: Robot watches both subsystems and triggers the feed when appropriate.
4. Swappable Interfaces Enable Experimentation
The DriveTrainBase interface lets us test different chassis configurations:
- TankDrive for the ball launcher
- MecanumDrive for holonomic movement
- SwerveDrive for ultimate flexibility
Same game code, different locomotion.
5. Keep Original Code As Reference
We kept lebot/ intact while building lebot2/. This provided:
- A working baseline if refactoring broke things
- Side-by-side comparison for learning
- Easy reference for hardware names and values
Don’t delete the old code until the new code is competition-tested.
6. Dual Localization Options Are Worth The Effort
Creating both TankDrive (encoder-based) and TankDrivePinpoint variants:
- Allows comparison testing on the actual robot
- Provides fallback if Pinpoint has issues
- Teaches team members how localization works
The small extra effort pays off in debugging time saved.
7. SDK Updates Break Things
The GoBildaPinpointDriver package move (org.firstinspires.ftc.teamcode → com.qualcomm.hardware.gobilda) broke 6 files across 3 robots.
Lesson: When updating SDK versions:
- Read the release notes fully
- Search codebase for any imported classes that moved
- Test build immediately after SDK update
- Commit SDK update separately from other changes
8. Unified OpMode Reduces Duplication
Having one OpMode that handles both Autonomous and TeleOp:
- Single initialization path (less bugs)
- Easy mode switching for testing
- Shared telemetry dashboard
- Natural transition from auton to teleop
The GameState enum makes this clean and explicit.
9. Virtual Sensors Abstract Physical Implementation
Virtual sensors are computed values derived from physical sensors that abstract implementation details from behavior code.
Why this matters: Initially, isFull() just checked ballCount >= MAX_BALLS. But this flickered when balls were at sensor thresholds or the count was momentarily wrong. The fix required:
- Combining ball count WITH back sensor confirmation
- Adding time-based debouncing (100ms stable before confirming)
Without virtual sensors, this fix would require updating every place that checked fullness. With virtual sensors, we changed one method and all behaviors automatically got the improved logic.
Pattern:
// Virtual sensor state
private boolean isFullConfirmed = false;
private long fullConditionStartMs = 0;
// In calc() - compute virtual sensor
boolean rawFull = (state == LoaderState.FULL) && ballAtBack;
if (rawFull) {
if (fullConditionStartMs == 0) fullConditionStartMs = System.currentTimeMillis();
isFullConfirmed = (System.currentTimeMillis() - fullConditionStartMs) >= FULL_CONFIRM_MS;
} else {
fullConditionStartMs = 0;
isFullConfirmed = false;
}
// Accessor - behaviors call this
public boolean isFull() { return isFullConfirmed; }
public boolean isFullRaw() { return state == LoaderState.FULL; } // For debugging
Benefits:
- Single source of truth - One definition of “full” used everywhere
- Easy to tune - Change
FULL_CONFIRM_MSin one place - Behavior code stays simple - Just calls
loader.isFull() - Testable - Can mock virtual sensor values independently
Other virtual sensors in lebot2:
Pose2dfrom Pinpoint odometryVision.getDistanceToGoal()from Limelight + AprilTags
10. Separate Strategy from Tactics with Missions
When building autonomous routines, we discovered a natural split between strategic and tactical concerns:
Strategic (Autonomous.java):
- Which missions to run
- What order to run them
- Adapting to starting position, partner agreements, opponent analysis
Tactical (Missions.java):
- How to execute each mission
- RoadRunner trajectory building
- State machine for each mission type
Why this split matters:
Initially, we put everything in Autonomous.java. The state machine grew unwieldy:
- Navigation code mixed with strategy decisions
- Hard to reuse ball collection logic
- Can’t call autonomous behaviors from TeleOp
The Missions layer solved these problems:
┌────────────────────────────────────────┐
│ Autonomous.java (Strategic) │
│ "Collect groups 0, 1, 2 then release"│
└───────────────┬────────────────────────┘
│ triggers
▼
┌────────────────────────────────────────┐
│ Missions.java (Tactical) │
│ "How to navigate to group 0" │
│ "How to release classifier" │
└───────────────┬────────────────────────┘
│ uses
▼
┌────────────────────────────────────────┐
│ Robot.Behavior / Subsystem.Behavior │
│ "TARGETING", "LAUNCH_ALL", etc. │
└────────────────────────────────────────┘
Key design decisions:
- Missions is a Robot field, not a subsystem - Because missions coordinate subsystems, not hardware
- calc() only when active -
if (missions.isActive()) missions.calc()conserves compute - Dual-use from Auto and TeleOp - Drivers can trigger
robot.missions.startBallGroup(0)during matches - Configurable order -
setBallGroupOrder({2, 1, 0})for different starting positions
Pattern for adding new missions:
// 1. Add to enum
public enum Mission { NONE, LAUNCH_PRELOADS, BALL_GROUP, OPEN_SESAME, NEW_MISSION }
// 2. Add mission-specific state enum
private enum NewMissionState { IDLE, STEP_1, STEP_2, COMPLETE }
// 3. Add start method
public void startNewMission(/* params */) {
currentMission = Mission.NEW_MISSION;
missionState = MissionState.RUNNING;
newMissionState = NewMissionState.IDLE;
}
// 4. Add case in calc()
switch (currentMission) {
case NEW_MISSION: updateNewMission(); break;
}
// 5. Implement state machine
private void updateNewMission() {
switch (newMissionState) {
case IDLE: /* build trajectory, start */ break;
case STEP_1: /* wait for trajectory */ break;
case STEP_2: /* activate subsystem behaviors */ break;
case COMPLETE: missionState = MissionState.COMPLETE; break;
}
}
Next Steps
Remaining work to complete the refactoring:
- Configure Pinpoint PARAMS - Measure pod offsets for lebot2 chassis
- Tune feedforward gains - Run RoadRunner tuning routines
Implement autonomous routines✓ - Missions.java + Autonomous.java implemented- Field test missions - Test BallGroup, OpenSesame, LaunchPreloads on actual field
- Measure field positions - Replace placeholder coordinates in Missions.java with real field measurements
- Implement GO_BALL_CLUSTER - Vision-based ball cluster pickup after classifier release
- Field test localization - Compare TankDrive vs TankDrivePinpoint accuracy
- Remove lebot/ - Once lebot2 is proven in competition
Last updated: January 2026
