General September 15, 2026

VRC 2024-25 Competitive Mobile Robot

This was my teams first competitive robot, built for the VRC High Stakes season. We won 2 Excellence Awards, a Tournament Champion title, and an Innovate Award across the season.

VEXC++Mobile RoboticsCADRobotics2D Control Systems
VRC 2024-25 Competitive Mobile Robot

Introduction

This was my teams first competition robot, built for the VRC High Stakes challenge. In High Stakes, rings are scored onto mobile goals and wall stakes, and any goal moved into a corner has its value either doubled or cancelled. Our scoring analysis found that doubled goals and negative corner goals were the two biggest prizes on the field. Each was worth just over a quarter of the 118 points available, more than any other method of scoring. Because of this, we chose to prioritize possessing goals and holding corners, rather than elevating at the end of the match. Every subsystem on the robot came out of that one decision. Our first robot paired a 450 RPM chassis with a hook intake and a two bar pivot lift. A pneumatic clamp held the goal steady while we filled it.


Hardware

Every mechanism started from the same 3 build constraints. The robot had to start inside an 18 by 18 by 18 inch volume, and draw no more than 88 watts of combined motor power. Every custom plastic part also had to fit onto a single 12 by 24 inch sheet, no thicker than 0.07 inches. We evaluated candidate designs with a decision matrix, meaning a scored table of options and criteria, with each design rated on speed, weight, and ease of build. Oftentimes the winning design was not the fastest one on paper, but rather the one we could build and repair between matches.

Chassis:

Our chassis was designed around the tradeoff between speed and pushing power. We scored 4 drivetrain configurations, and a 450 RPM setup on 3.25 inch wheels won with 29.3 points out of 30. That configuration travels 6.3 feet per second, which is quick enough to reach a corner first without surrendering the torque needed to hold it. We powered it with six 11 watt motors through a 36:48 gear reduction, running 4 omni wheels for turning and 2 traction wheels for push resistance. Testing put a full field traverse at 1.73 seconds, averaged over 3 trials.

After our first tournament the chassis became the one subsystem we rebuilt. Its center of gravity sat behind the center of the frame, which made the robot prone to tipping, and which sank the drive down into the foam tiles. The bottom of the intake crashed into the ground as a result. We lengthened the frame by 7 holes, swapping the 23 inch C-channel rails for 30 inch rails, and added 2 more powered omni wheels. Distributing the same weight across 8 points of contact removed the sinking and squared us up against defense, without touching gearing we already trusted.

Intake and Lift:

The intake is a hook system, meaning a chain run of plastic hooks that catches rings at floor level, carrying each ring up and over a mobile goal. It scored 25 out of 40 on our intake matrix, winning on speed and accuracy against flex wheel, conveyor, and claw designs. Our first full robot test failed 2 of 3 trials, since rings struck the goal stake and dropped to the floor instead of seating. Through testing, we built a parameter chart mapping how hook height, shaft termination distance, and hook speed each moved where a ring landed. Raising the hooks alone did not fix it. Instead, extending and raising the termination shaft with a 5x L-channel threw the ring past the stake.

The lift is a two bar, meaning a single pivoting linkage that carries the entire intake up to wall stake height. It won its matrix at 36 out of 40, beating a four bar, a chain bar, and a double reverse four bar. Most of that margin, predictably, came from weight and ease of build. Both mechanisms needed repair work during the season. A 1x33 beam on the lift snapped in the middle, from the constant tension of the intake hanging out past the pivot. We replaced it with a halfcut 3x33 C-channel, adding material along the axis under load.

By December our reflection showed that our wall stake cycle was slower than most competitive designs, and that faster teams were reaching the center goal ahead of us. We scored 3 replacements: an actuated hook stage, a six bar ring redirect, and a two bar deposit mechanism. The redirect could hold 2 rings at once, which made it attractive on paper. However every team we watched running one had to clear jams mid match. We selected the two bar deposit lift, which scored 26 out of 30. It terminates on the opposite side of the robot from the top intake shaft, dropping a ring straight onto the stake.

Sweeper:

Four rings sit stacked in each corner of the field, and clearing them was costing us possession of corners we had already won. A sweeper is an arm that extends off the side of the chassis, wedging between the ring stack and the field perimeter. From there, the robot pivots and drags the whole stack out. The obvious solution would be a fixed protruding wedge, which needs no air and no moving parts. However a fixed wedge only clears one ring per pass, which made it far too slow against a stack of 4. We built an actuated arm instead, a 2x22 C-channel on pillow block bearings driven by a single acting piston. A polycarbonate face keeps the whole stack in front of the arm.

Hang:

Late in the season we added a hang, meaning a mechanism that lifts the robot onto the center ladder, which is worth 3 to 12 points in the last 30 seconds of a match. We had no motor ports and no air left to spend, so we constrained the design to passive components only. A passive hang scored 36 out of 40 against a winch mechanism and a piston mechanism, winning almost entirely on the resources it did not consume. The final design mounts 2 halfcut 3x16 C-channels to the two bar frame, held under tension with latex tubing. Standoffs 1.5 inches long grip the rung itself.

Manufacturing:

Every custom part on this robot was cut, drilled, and bent by hand. I modelled each piece in Fusion 360 using the sheet metal tools. Those tools let me design a bent part, then unfold it into a flat pattern, or a printable outline of that same geometry laid flat. I printed the pattern at scale, taped it directly onto the polycarbonate, and cut and drilled straight through the paper. Bends were formed afterward with a heat gun, including a 70 degree bend on the plate that sets our clamp tilt angle. Working this way kept us inside the single sheet limit, and let us replace a broken part the same night it broke.


Software

The software is written in C++ on PROS, an open source real time operating system for the V5 brain, with LemLib handling chassis movement on top of it. We ran a modified fork of that library, versioned as LemLib-2147, so that we could add features upstream did not provide. Each subsystem lives in its own class with its own background task, meaning the intake and the lift update on independent 20 millisecond loops, no matter what the driver code is doing. Therefore, driver control and autonomous share one interface into every mechanism on the robot.

PID Controller

A PID controller is a feedback loop that computes motor output from 3 measurements of error, meaning its current value, its accumulated total, and its rate of change. P drives the robot toward the target, and D slows it as it closes in. I corrects for small steady offsets that P alone is too weak to overcome. We tuned the lateral controller to 10, 0.05, and 60, and the angular controller to 7.3, 0.0075, and 78.6.

v(t)=Kpe(t)+Ki0te(τ)dτ+Kdde(t)dtv(t) = K_p e(t) + K_i \int_{0}^{t} e(\tau) \, d\tau + K_d \frac{de(t)}{dt}
c++
//full code on github repository

void Lift::moveToPID(float position) {
double rotationData = static_cast<double>(liftRotation.get_angle()) / 36000.0;
if (rotationData > .875) { rotationData = 0; }

float error = position - rotationData;
liftMotor.move(liftPID.update(error));
}

The same controller runs our lift off a rotation sensor, rather than a motor encoder. Dividing the raw centidegree reading by 36000 converts it into rotations. The guard on line 5 exists because that sensor wraps from 1.0 back to 0 at the bottom of travel. Without that line, a wrapped reading would command the lift to drive a full rotation backwards.

Odometry

Odometry is a localization method that reports where the robot sits in field coordinates. It tracks position itself, rather than how far the robot has driven since the last command. We measure that position with 2 unpowered tracking wheels, mounted perpendicular to one another. A 2.75 inch horizontal tracker sits 3.5 inches off center, with a 2 inch vertical tracker offset 0.75 inches. An inertial sensor supplies our heading. Every loop we read how far each tracker has turned, then rotate that local movement by our heading to update a global X and Y.

Xt=Xt1+Δysin(θ)+Δxcos(θ)X_t = X_{t-1} + \Delta y sin(\theta) + \Delta x cos(\theta)
Yt=Yt1+Δycos(θ)Δxsin(θ)Y_t = Y_{t-1} + \Delta y cos (\theta) -\Delta x sin (\theta)

Because the pose is field oriented, retuning a routine means changing one coordinate, rather than rewriting every movement that follows it.

Odometry Reset

Our December reflection found the real weakness of this approach. Rounding errors in each update are individually negligible, but across a 60 second skills run they compound, until the reported pose no longer matches the field. A GPS sensor would solve this directly, since it reads global position off strips mounted around the perimeter. However those strips are not guaranteed at every competition, which made the sensor a dependency we could not build a season around. I added a distanceReset class to our LemLib fork instead, using 2 distance sensors aimed at perpendicular walls.

c++
//full code on github repository

lemlib::distanceReset reset(xRobotDistance, yRobotDistance, 6, 5);

// mid-routine, facing into quadrant II
reset.reset(lemlib::x, lemlib::y, lemlib::II, 10);

The class is constructed with both sensors and their offsets from the tracking center, in inches. At the call site we name which sensor reads which axis, and which quadrant of the field the robot occupies. A final argument sets how many samples to take. It averages 10 filtered readings off each wall, converts them into an absolute field position, and writes that pose straight back into odometry. Resetting against the perimeter this way let our skills routine correct itself halfway through, rather than compounding error all the way to the end.

AutoBuilder

Building an autonomous routine normally means estimating a coordinate, running it, and editing the number after watching the robot miss. AutoBuilder is a tool we wrote that removes the estimation entirely. We push the robot around the field by hand with odometry running. At each position we want, a controller button prints the matching LemLib call to the brain terminal, with the live pose already filled in. A is a drive to point, X is a turn to heading, and B is a turn to a point. The remaining buttons print our lift, intake, and clamp calls.

c++
//full code on github repository

if (controller.get_digital_new_press(pros::E_CONTROLLER_DIGITAL_A)) {
printf("chassis.moveToPoint(%f, %f, 1500, {}, false); \n",
chassis.getPose().x, chassis.getPose().y);
} else if (controller.get_digital_new_press(pros::E_CONTROLLER_DIGITAL_X)) {
printf("chassis.turnToHeading(%f, 500); \n", chassis.getPose().theta);
}
flowchart TB
    subgraph manual["Manual Positioning"]
        push["Push Robot by Hand"] --> odom["Odometry Task Updates Pose"]
    end

    subgraph capture["AutoBuilder Loop (20ms)"]
        poll["Poll Controller"]
        poll -->|A| mtp["print moveToPoint"]
        poll -->|X| tth["print turnToHeading"]
        poll -->|B| ttp["print turnToPoint"]
        poll -->|L1, R1, R2| sub["print Subsystem Call"]
        mtp --> term["Brain Terminal"]
        tth --> term
        ttp --> term
        sub --> term
    end

    odom --> poll
    term --> paste["Paste Into Auton Function"]

Writing a routine became a matter of driving it once and pasting the output. Therefore, we ended the season carrying 21 tuned routines on the selector. A bad field or a broken part also stopped costing us a full night of retuning. In practice, rebuilding a routine took a single run.

Subsystem States

A state machine is a pattern that holds a subsystem in exactly one of several named states, with defined rules for moving between them. Our intake runs 4 of them. Regular drives the motor at a set voltage, ejecting reverses a wrong colored ring back out, and relative moves the hooks a fixed distance to seat a ring. Its background task picks one state every 20 milliseconds, before it touches a motor. On every single pass, two behaviors run ahead of that switch. Color sorting compares the optical sensors hue against our alliance range, and flags an opposing ring for ejection. Anti jam watches for a motor drawing over 1800 milliamps at zero efficiency, then reverses it briefly to clear.

c++
//full code on github repository

void Intake::intakeTask() {
autoEject();
antiJam();

switch (currentState) {
case regular:
intakeMotor.move(intakeVoltage);
break;

case ejecting:
while (intakeOptical.get_proximity() > distanceThreshold) {
intakeMotor.move(127);
}
delay(5);
intakeMotor.move(-127);
delay(80);
setState(regular);
break;
}

delay(20);
}

The lift is structured the same way, resolving its state into a target height and feeding that height to its own PID loop. Our clamp adds a task that watches a distance sensor and closes on its own once a goal is inside. The driver holds a heading toward the goal, and the robot handles the timing. Building each mechanism this way meant a change to one state propagated instantly into all 21 autonomous routines, without editing any of them.