Subsystem-File Architecture
main.cpp declares the hardware. subsystems.hpp re-declares it as extern so other files can reference it. autons.cpp and any new mechanism file consumes those externs.
flowchart LR
main[main.cpp
declarations
initialize
opcontrol]
sub[subsystems.hpp
extern declarations
extern pros::Motor arm
extern pros::Imu drive_imu]
autons[autons.cpp
autonomous routines]
arm[arm.cpp
arm-specific helpers
arm_init, arm_control]
other[other .cpp files
any future mechanism]
main -.->|"exposes via"| sub
sub --> autons
sub --> arm
sub --> other
style main fill:#0c4a6e,color:#e2e8f0,stroke:#22d3ee,stroke-width:2px
style sub fill:#3f1d77,color:#e2e8f0,stroke:#a78bfa,stroke-width:2px
style autons fill:#1e293b,color:#e2e8f0,stroke:#334155
style arm fill:#1e293b,color:#e2e8f0,stroke:#334155
style other fill:#1e293b,color:#94a3b8,stroke:#334155
After your code from the Bringup Center works, refactor it to per-mechanism files. main.cpp becomes a 10-line orchestration file. Subsystems are encapsulated, testable, and reusable across robots.
SECTION 1Why subsystem files
The Bringup Center teaches you to put everything in main.cpp — that's the right approach for learning one mechanism at a time. But once you have 3+ subsystems, main.cpp becomes a 400-line monster:
- Hard to read — opcontrol has 80 lines of mixed button-handling logic for every mechanism. Finding "where does the tube actually move" requires scrolling.
- Hard to debug — when the claw misbehaves you can't comment out the claw code without surgically extracting it.
- Hard to hand off — two students editing main.cpp at the same time always produces merge conflicts.
- Hard to reuse — the claw logic on Clawbot is identical to the claw logic on Flex, but it's intertwined with everything else.
The fix is the same pattern professional embedded software uses: separation of concerns by subsystem. One .cpp file per mechanism. main.cpp becomes a thin orchestration layer that says "call the lift, call the intake, call the tube" in a loop — nothing more.
opcontrol() function in main.cpp should fit in 10 lines or less, regardless of how many mechanisms the robot has. If it doesn't, mechanism control logic is still leaking into main — finish the refactor.
SECTION 2The file structure
Every Spartan robot project follows this layout. Files marked NEW are the ones we add beyond what the EZ-Template starter gives you.
.hpp + .cpp in its own folder under src/ makes ownership crystal clear. Everything about the arm lives in src/arm/. To find or modify a subsystem, you open one folder — no jumping between src/ and include/. Only project-wide headers (the master main.h, the shared robot-config.hpp, the auton declarations in autons.hpp) stay in include/.
.cpp under src/ automatically (uses recursive wildcard), so adding subsystem folders just works for compilation. For headers in src/<name>/ to be discoverable from main.cpp and other files, add one line to the project Makefile: EXTRA_INCDIR := $(ROOT)/src. The reference projects already have this configured.
SECTION 3The four convention rules
These rules pay off as the codebase grows. Stick to them and code from Clawbot becomes copy-pasteable to Flex, code from Flex becomes copy-pasteable to a competition robot.
Every subsystem exposes <name>_init() and <name>_control(master) | Predictable. Anyone reading main.cpp knows what to expect: each mechanism has exactly one init call and one control call. |
State variables are static at file scope | Compiler-enforces "this state belongs to this subsystem." If main.cpp tries to write to claw_open, you get a linker error — proving the encapsulation. |
| Power and timing constants live at the top of the subsystem file | Tuning happens by opening one file, scrolling to the top. LIFT_POWER, TUBE_PULSE_TICKS, etc. are visible immediately. |
robot-config.cpp is the ONLY place motor / sensor objects are defined | Single source of truth for port assignments. All other files use extern declarations via robot-config.hpp. |
SECTION 4main.cpp + robot-config — works for every robot
This skeleton works for Clawbot, Flex, and any competition robot. The only thing that changes per-robot is which subsystem files exist and which control functions are called in the opcontrol loop.
The thin main.cpp
Notice what's NOT here: no mechanism motor commands, no button-press logic, no state variables for toggles. All of that lives inside the subsystem files.
#include "main.h"
void initialize() {
pros::lcd::initialize();
pros::lcd::print(0, "Calibrating IMU...");
chassis.drive_imu_calibrate();
chassis.drive_brake_set(pros::E_MOTOR_BRAKE_COAST);
// Each subsystem initializes its own brake mode, current limits, encoders, etc.
// Comment out a line here to test that mechanism is disabled.
arm_init(); // or lift_init() depending on the robot
claw_init();
// intake_init(); // uncomment per robot
// tube_init(); // uncomment per robot
// pneumatics_init();
default_constants();
ez::as::initialize();
ez::as::auton_selector.autons_add({
Auton("Drive + Turn (Ex 1)", exercise1_drive_and_turn),
Auton("Drive a Square (Ex 2)", exercise2_square),
});
}
void disabled() {}
void competition_initialize() {}
void autonomous() {
chassis.pid_targets_reset();
chassis.drive_imu_reset();
chassis.drive_sensor_reset();
chassis.drive_brake_set(pros::E_MOTOR_BRAKE_HOLD);
ez::as::auton_selector.selected_auton_call();
}
void opcontrol() {
pros::Controller master(pros::E_CONTROLLER_MASTER);
while (true) {
// Drive (one line β split arcade or tank, your call)
chassis.opcontrol_tank();
// chassis.opcontrol_arcade_standard(ez::SPLIT); // for a competition robot
// Mechanisms (one line each β add or remove per robot)
arm_control(master);
claw_control(master);
// intake_control(master);
// tube_control(master);
// pneumatics_control(master);
pros::delay(20);
}
}
The master include — main.h
One line per subsystem header. When you add a new mechanism, you add one #include here and the rest of the codebase can see it.
#pragma once // PROS + EZ-Template framework #include "api.h" #include "EZ-Template/api.hpp" // Hardware definitions (extern declarations) #include "robot-config.hpp" // Auton declarations #include "autons.hpp" // Subsystem control headers β path-qualified since headers live in src/<name>/ #include "arm/arm.hpp" // or "lift/lift.hpp" depending on the robot #include "claw/claw.hpp" // #include "intake/intake.hpp" // uncomment per robot // #include "tube/tube.hpp" // uncomment per robot // #include "pneumatics/pneumatics.hpp" // uncomment per robot
robot-config — the single source of truth for hardware
Every motor, sensor, pneumatic on the robot is defined in robot-config.cpp and declared as extern in robot-config.hpp. When ports change at practice (someone swapped a motor), you update one place.
#pragma once #include "api.h" #include "EZ-Template/api.hpp" // Drivetrain (managed by EZ-Template) extern ez::Drive chassis; // Mechanism motors (raw PROS β EZ-Template only handles chassis) extern pros::Motor arm; // for Clawbot // extern pros::Motor lift; // for Flex // extern pros::Motor lift_left, lift_right; // for a 2-motor lift extern pros::Motor claw; // extern pros::Motor intake; // extern pros::Motor tube; // Sensors (uncomment what's wired) // extern pros::Optical claw_optical; // extern pros::Distance back_distance; // extern pros::adi::DigitalIn arm_top_limit; // extern pros::adi::Potentiometer arm_pot; // Pneumatics (if equipped) // extern pros::adi::DigitalOut tube_cinch; // extern pros::adi::DigitalOut aligner;
#include "main.h"
// Clawbot example β replace the values for your robot
ez::Drive chassis(
{-1}, // Left drive ports (reversed = negative)
{10}, // Right drive ports
11, // IMU port
4.0, // Wheel diameter in inches
200.0, // Cartridge RPM (200=green, 600=blue)
1.0 // External gear ratio
);
pros::Motor arm (8, pros::v5::MotorGears::red);
pros::Motor claw(3, pros::v5::MotorGears::red);
// pros::Optical claw_optical(5);
// pros::Distance back_distance(13);
// pros::adi::DigitalIn arm_top_limit('A');
// pros::adi::DigitalIn arm_bot_limit('B');
// pros::adi::Potentiometer arm_pot('C', pros::E_ADI_POT_EDR);
SECTION 5Per-robot subsystem files
Each robot's section is collapsed by default. Open one to see its subsystem implementations. Notice that claw.cpp is line-for-line identical between Clawbot and Flex — that's the architecture earning its keep.
CLAWBOT Clawbot — arm + claw
Two mechanisms: arm (hold-to-move, L1/L2) and claw (rising-edge toggle, R1). Both are raw PROS motors — EZ-Template only manages the chassis.
#pragma once #include "api.h" void arm_init(); void arm_control(pros::Controller& master);
#include "main.h"
const int ARM_POWER = 80; // tune here, not in main.cpp
void arm_init() {
arm.set_brake_mode(pros::v5::MotorBrake::hold); // doesn't sag under gravity
arm.tare_position(); // encoder starts at 0
}
void arm_control(pros::Controller& master) {
if (master.get_digital(pros::E_CONTROLLER_DIGITAL_L1)) {
arm.move(ARM_POWER); // arm up
} else if (master.get_digital(pros::E_CONTROLLER_DIGITAL_L2)) {
arm.move(-ARM_POWER); // arm down
} else {
arm.brake(); // HOLD position
}
}
#pragma once #include "api.h" void claw_init(); void claw_control(pros::Controller& master);
#include "main.h"
// File-scope state β 'static' keeps these invisible outside this file
static bool claw_open = false;
static bool last_btn_r1 = false;
void claw_init() {
claw.set_brake_mode(pros::v5::MotorBrake::hold);
claw.tare_position();
}
void claw_control(pros::Controller& master) {
bool r1 = master.get_digital(pros::E_CONTROLLER_DIGITAL_R1);
// Rising-edge detection: was NOT pressed last loop, IS pressed this loop
if (r1 && !last_btn_r1) {
claw_open = !claw_open;
if (claw_open) claw.move_absolute(200, 50);
else claw.move_absolute(0, 50);
}
last_btn_r1 = r1; // remember for next loop
}
static matters: the claw_open and last_btn_r1 variables are file-private. If main.cpp accidentally tries to read or write them, the compiler refuses. This is encapsulation enforced by the language itself.FLEX Flex — lift + claw
Two mechanisms: lift (hold-to-move, L1/L2) and claw (rising-edge toggle, R1). Same patterns as Clawbot — the claw.cpp file is literally identical (you copy it from the Clawbot project).
#pragma once #include "api.h" void lift_init(); void lift_control(pros::Controller& master);
#include "main.h"
const int LIFT_POWER = 80;
void lift_init() {
lift.set_brake_mode(pros::v5::MotorBrake::hold);
lift.tare_position();
}
void lift_control(pros::Controller& master) {
if (master.get_digital(pros::E_CONTROLLER_DIGITAL_L1)) {
lift.move(LIFT_POWER);
} else if (master.get_digital(pros::E_CONTROLLER_DIGITAL_L2)) {
lift.move(-LIFT_POWER);
} else {
lift.brake();
}
}
#include "main.h"
static bool claw_open = false;
static bool last_btn_r1 = false;
void claw_init() {
claw.set_brake_mode(pros::v5::MotorBrake::hold);
claw.tare_position();
}
void claw_control(pros::Controller& master) {
bool r1 = master.get_digital(pros::E_CONTROLLER_DIGITAL_R1);
if (r1 && !last_btn_r1) {
claw_open = !claw_open;
if (claw_open) claw.move_absolute(200, 50);
else claw.move_absolute(0, 50);
}
last_btn_r1 = r1;
}
claw.cpp drops in unchanged. The control code never gets rewritten. Only robot-config.cpp changes per-robot (different motor port).COMP BOT Competition bot — lift + intake + tube + pneumatics
Five subsystems: lift (2-motor sync, R1/R2), intake (held forward/reverse, L1/L2), tube (non-blocking timed pulses, X/Y), and pneumatics (cinch + aligner toggles, A/B). Each one is its own file.
#pragma once #include "api.h" void lift_init(); void lift_control(pros::Controller& master);
#include "main.h"
const int LIFT_POWER = 110;
void lift_init() {
lift_right.set_reversed(true); // mounted facing left motor
lift_left.set_brake_mode (pros::v5::MotorBrake::hold); // doesn't sag
lift_right.set_brake_mode(pros::v5::MotorBrake::hold);
lift_left.set_current_limit (2500); // 2.5A each = 5A total
lift_right.set_current_limit(2500);
}
void lift_control(pros::Controller& master) {
if (master.get_digital(pros::E_CONTROLLER_DIGITAL_R1)) {
lift_left.move( LIFT_POWER); lift_right.move( LIFT_POWER);
} else if (master.get_digital(pros::E_CONTROLLER_DIGITAL_R2)) {
lift_left.move(-LIFT_POWER); lift_right.move(-LIFT_POWER);
} else {
lift_left.brake(); lift_right.brake(); // HOLD
}
}
#pragma once #include "api.h" void intake_init(); void intake_control(pros::Controller& master);
#include "main.h"
void intake_init() {
intake.set_reversed(true); // positive = pin-in
intake.set_brake_mode(pros::v5::MotorBrake::coast); // spins down naturally
intake.set_current_limit(2000); // 2A β jam protection
}
void intake_control(pros::Controller& master) {
if (master.get_digital(pros::E_CONTROLLER_DIGITAL_L1)) intake.move( 127);
else if (master.get_digital(pros::E_CONTROLLER_DIGITAL_L2)) intake.move(-127);
else intake.brake();
}
#pragma once #include "api.h" void tube_init(); void tube_control(pros::Controller& master);
#include "main.h"
const int TUBE_PULSE_TICKS = 30; // 30 * 20ms = 600ms pulse
const int TUBE_POWER = 80;
// File-scope state β invisible to main.cpp
static int tube_timer = 0;
static int tube_direction = 0; // -1, 0, or +1
void tube_init() {
tube.set_brake_mode(pros::v5::MotorBrake::brake); // snappy stop
tube.set_current_limit(1500);
}
void tube_control(pros::Controller& master) {
// Detect button press (rising edge) β kick off the state machine
if (master.get_digital_new_press(pros::E_CONTROLLER_DIGITAL_X)) {
tube_timer = TUBE_PULSE_TICKS; tube_direction = +1;
} else if (master.get_digital_new_press(pros::E_CONTROLLER_DIGITAL_Y)) {
tube_timer = TUBE_PULSE_TICKS; tube_direction = -1;
}
// Run motor while counter > 0, brake when it hits 0
if (tube_timer > 0) {
tube.move(TUBE_POWER * tube_direction);
tube_timer--;
} else {
tube.brake();
tube_direction = 0;
}
}
tube.move(80); pros::delay(600); — which freezes the WHOLE opcontrol loop for 600ms. Driver can't drive during the rotation. The state-machine pattern lets everything else (drive, lift, intake, pneumatics) keep running.#pragma once #include "api.h" void pneumatics_init(); void pneumatics_control(pros::Controller& master);
#include "main.h"
// File-scope state β boot defaults match the physical setup
static bool cinch_open = true; // cinch starts OPEN
static bool aligner_open = false; // aligner starts CLOSED
void pneumatics_init() {
tube_cinch.set_value(cinch_open);
aligner.set_value(aligner_open);
}
void pneumatics_control(pros::Controller& master) {
if (master.get_digital_new_press(pros::E_CONTROLLER_DIGITAL_A)) {
cinch_open = !cinch_open;
tube_cinch.set_value(cinch_open);
}
if (master.get_digital_new_press(pros::E_CONTROLLER_DIGITAL_B)) {
aligner_open = !aligner_open;
aligner.set_value(aligner_open);
}
}
A competition robot's opcontrol — what main.cpp looks like at the end
void opcontrol() {
pros::Controller master(pros::E_CONTROLLER_MASTER);
while (true) {
chassis.opcontrol_arcade_standard(ez::SPLIT); // drive
lift_control(master);
intake_control(master);
tube_control(master);
pneumatics_control(master);
pros::delay(20);
}
}
main.cpp immediately knows: the robot has a lift, intake, tube, and pneumatics. Each one lives in a separate file. Bug in the tube? Open tube.cpp — nothing else to scroll past.SECTION 6Refactoring from the bringup pattern
If you've already built your code using the Bringup Center pattern (everything in main.cpp), here's how to refactor to subsystem files. Do this only after the code already works. Refactoring broken code is a recipe for losing track of which problem you're solving.
The 5-step refactor
- Make sure your bringup code works. All mechanisms respond to buttons correctly. The refactor preserves behavior, so test before and after — the robot should behave identically.
- Create the empty subsystem files. For each mechanism, create a folder
src/<name>/containing bothsrc/<name>/<name>.cppandsrc/<name>/<name>.hpp. (Headers used to live ininclude/, but the subsystem-folder layout co-locates each mechanism's two files. Only project-wide headers —main.h,autons.hpp,robot-config.hpp— stay ininclude/.) Start the new files with just function signatures and empty bodies. - Move one mechanism at a time. Cut the initialization code from
initialize()inmain.cppand paste into<name>_init()insidesrc/<name>/<name>.cpp. Cut the button-handling block fromopcontrol()and paste into<name>_control(master). Updatemain.hto#include "<name>/<name>.hpp"(path-qualified). - Move state variables. Any
bool claw_open,int tube_timer, etc. that lived at file scope inmain.cppmoves to the subsystem file. Addstaticto make it file-private. - Build and test. One subsystem at a time. Run after each move — the robot should behave exactly as before. If something breaks, you know which mechanism's refactor introduced the bug.
initialize() orchestration (calling each _init()), the opcontrol() loop (calling each _control()), the autonomous() stub, and the disabled/competition_initialize stubs. Everything mechanism-specific moves out.
extern for motor objects in robot-config.hpp. If you get a linker error like "undefined reference to `arm`", that's the cause — the subsystem file can't see the motor object yet.
SECTION 7Reference VS Code projects
Working EZ-Template projects pre-configured in the subsystem-file architecture. Download, extract, open the folder in VS Code with the PROS extension, and you have a baseline to build on.
main.cpp to yours — youβll see immediately what the refactor produces.
EN4 reminder: reference projects are for studying the architecture, not for turn-in. Your team must write its own version of every subsystem file before competition — the references show the structure, not ready-made answers.