From an empty project to a tuned, odometry-driven autonomous: install LemLib, wire up tracking wheels, configure the chassis, calibrate, tune the PIDs, and write motions that drive to coordinates instead of guesses. Work top to bottom the first time; after that, use it as a reference.
github.com/LemLib/LemLib, and targets PROS kernel 4.2.1. It's a third-party library β if you use it, cite it in your notebook. Commands and a couple of API signatures change between releases, so where exact syntax matters this guide links the official docs at lemlib.readthedocs.io β check them against the version you install.
Once it's set up, LemLib coding is mostly picking the right motion call. This tree maps "what I want" to the call.
flowchart TD
Start([What do you
want the bot to do?]) --> Q1{Drive or turn?}
Q1 -->|"Drive to a spot
(heading free)"| MP[chassis.moveToPoint
x, y, timeout]
Q1 -->|"Drive to a spot
AND face a way"| MPose[chassis.moveToPose
x, y, theta, timeout]
Q1 -->|"Turn to a heading"| TH[chassis.turnToHeading
theta, timeout]
Q1 -->|"Turn to face a point"| TP[chassis.turnToPoint
x, y, timeout]
Q1 -->|"Follow a whole path"| FL[chassis.follow
path, lookahead, timeout]
MP --> Wait[Then: chassis.waitUntilDone
blocks until the motion ends]
MPose --> Wait
TH --> Wait
TP --> Wait
FL --> Wait
Wait --> Mid{Act mid-move?}
Mid -->|"Yes"| WU[chassis.waitUntil dist
fire intake / lift, keep driving]
Mid -->|"No"| Next([Next step])
WU --> Next
style Start fill:#1e293b,stroke:#22d3ee,stroke-width:2px,color:#e2e8f0
style Q1 fill:#fbbf24,color:#0f172a,stroke:#fbbf24
style Mid fill:#fbbf24,color:#0f172a,stroke:#fbbf24
pros conduct new-project. Confirm the kernel β LemLib targets 4.2.1.github.com/LemLib/LemLib (the .zip), then fetch and apply it:# from your project folder, in the PROS terminal
pros c fetch LemLib@0.5.0.zip
pros c apply LemLib@0.5.0
pros c add-depot then pros c apply LemLib. Use whichever the install page lists.)// main.cpp (and any file that calls chassis) #include "lemlib/api.hpp"
pros build. A clean build means LemLib is installed against your kernel. A "template not compatible" error usually means a kernel mismatch β see Common errors.Odometry works by adding up how far two unpowered tracking wheels roll: a vertical wheel (rolls as you drive forward/back) and a horizontal wheel (rolls as you slide sideways during turns and arcs). The IMU supplies heading. Each wheel's offset is its perpendicular distance from the robot's tracking center β and getting the sign and size right is what makes the pose accurate.
Configuration is four objects: the drivetrain, the odom sensors, two PID controllers (one for driving straight, one for turning), and the chassis that ties them together.
pros::MotorGroup leftMotors({-1, -2, -3}); // negative port = reversed pros::MotorGroup rightMotors({4, 5, 6}); lemlib::Drivetrain drivetrain( &leftMotors, &rightMotors, 11.5, // track width: left-to-right wheel distance (in) lemlib::Omniwheel::NEW_325, // wheel diameter (3.25") 450, // drive RPM (motor RPM x gear ratio) 2 // horizontal drift: 2 = traction wheels, ~8 = all-omni );
| Param | What it is | How to get it |
|---|---|---|
| track width | Distance between left & right wheels | Measure center-to-center; fine-tune in Β§5 |
| wheel diameter | Drive wheel size | Use the Omniwheel constant (NEW_275/325/4) |
| drive RPM | Free speed of the drive | Cartridge RPM Γ external gear ratio |
| horizontal drift | How much the bot slides sideways | 2 if you run traction wheels; higher for all-omni |
pros::Rotation vertRot(11); pros::Rotation horiRot(12); pros::Imu imu(10); // TrackingWheel(sensor, wheel diameter, offset from tracking center) lemlib::TrackingWheel vertical(&vertRot, lemlib::Omniwheel::NEW_2, -2.5); lemlib::TrackingWheel horizontal(&horiRot, lemlib::Omniwheel::NEW_2, -1.0); // OdomSensors(vertical1, vertical2, horizontal1, horizontal2, imu) // pass nullptr for any wheel you don't have lemlib::OdomSensors sensors(&vertical, nullptr, &horizontal, nullptr, &imu);
// lateral = straight-line driving lemlib::ControllerSettings lateral( 10, 0, 3, // kP, kI, kD 3, // anti-windup range 1, 100, // small error (in), small timeout (ms) 3, 500, // large error (in), large timeout (ms) 20 // slew (max accel; 0 = off) ); // angular = turning lemlib::ControllerSettings angular(2, 0, 10, 3, 1, 100, 3, 500, 0);
lemlib::Chassis chassis(drivetrain, lateral, angular, sensors); void initialize() { chassis.calibrate(); // calibrates IMU + starts odometry β keep the robot still! }
calibrate(), print the pose to the screen in a loop and push the robot by hand. x, y, and theta should change sensibly. If pose stays (0,0), see Common errors.while (true) { auto p = chassis.getPose(); pros::lcd::print(0, "x %.1f y %.1f h %.1f", p.x, p.y, p.theta); pros::delay(20); }
y is off, re-check your tracking-wheel diameter and that the wheel actually spins freely on the floor.Tune lateral (driving) and angular (turning) separately. The method is the same for both:
kP until the robot reaches the target briskly and just barely overshoots / oscillates. Too low = sluggish and stops short; too high = wild oscillation.kD until the oscillation settles into one clean approach with little or no bounce. kD resists sudden change β it's the brake.kI (with the anti-windup range) nudges it the rest of the way. Most setups leave kI at 0.slew (lateral) if the bot jerks at the start of a move.The lateral gains in Β§4 (kP 10, kD 3) didn't come from nowhere. Here's the session that produced them β a ~14 lb bot on 3.25" wheels, driving 24". kI stays 0 the whole time.
| Try | kP | kD | What the robot did | Decision |
|---|---|---|---|---|
| 1 | 4 | 0 | Crept in slowly, stopped ~1.5" short | kP too low β raise it |
| 2 | 10 | 0 | Reached target, overshot ~1" and rocked back twice | Good speed; add kD to damp |
| 3 | 10 | 2 | One small overshoot, then settled | Close β a touch more kD |
| 4 | 10 | 3 | Drove in and stopped, no bounce | ✅ keep |
The angular controller gets the same four-step treatment on an in-place 90° turn, landing on kP 2, kD 10 β turns settle quickly, so it tolerates a higher kD-to-kP ratio. Those are exactly the Β§4 constants.
// where the robot starts: x, y (in), heading (deg) chassis.setPose(0, 0, 0);
chassis.moveToPoint(24, 24, 2000); chassis.waitUntilDone();
// arrive at x,y FACING theta β curves in chassis.moveToPose(24, 24, 90, 3000); chassis.waitUntilDone();
chassis.moveToPoint(0, -24, 2000, {.forwards = false, .maxSpeed = 80});
chassis.turnToHeading(90, 1000); chassis.turnToPoint(24, 48, 1000);
auto pose = chassis.getPose(); // pose.x, pose.y, pose.theta
moveToPoint when you only care where you end up. Use moveToPose (the boomerang controller) when the final heading matters β it arcs so it arrives lined up. The lead param (0β1) controls how wide that arc is.Motions run on a background task by default, so your code keeps going. Block when you need to with waitUntilDone(), or act partway through a move with waitUntil(distance) β that's how you fire an intake or lift without stopping the drive.
chassis.moveToPoint(0, 40, 2500); chassis.waitUntil(20); // once 20" into the move... intake.move(127); // ...start intake, drive keeps going chassis.waitUntilDone(); // now wait for the drive to finish chassis.cancelMotion(); // abort current motion (e.g. on a line/sensor trigger) chassis.cancelAllMotions();
.txt file and drop it in your project's static/ folder.ASSET() macro to embed the file, then follow it with a lookahead distance and a timeout:ASSET(path_txt); // embeds static/path.txt // follow(path, lookahead(in), timeout(ms), forwards) chassis.follow(path_txt, 15, 4000); chassis.waitUntilDone();
You need to get from the start (0, 0) to a scoring spot at (36, 48), but a stack sits at about (18, 24) right in the lane. A straight moveToPoint would plow into it β a drawn path can S-curve around it.
(0,0) → (4,18) → (28,30) → (36,48). The curve clears the stack on the left, then sweeps back to the goal. Export as scurve.txt into static/.ASSET(scurve_txt); chassis.setPose(0, 0, 0); chassis.follow(scurve_txt, 12, 4000); // lookahead 12in, 4s timeout chassis.waitUntilDone();
forwards = false. LemLib follows the same path driving backward:chassis.follow(scurve_txt, 12, 4000, false); // forwards = false chassis.waitUntilDone();
moveToPoint / moveToPose is simpler and self-corrects with odometry. Reach for follow when the route matters β dodging an obstacle, hugging a wall, or running a specific lane you practiced.void autonomous() { chassis.setPose(0, 0, 0); // start at origin, facing up-field // 1) drive to the first scoring spot chassis.moveToPoint(0, 30, 2000); chassis.waitUntil(22); // part-way in... intake.move(127); // ...spin intake, keep driving chassis.waitUntilDone(); intake.move(0); // 2) face the goal and approach lined up chassis.moveToPose(24, 36, 90, 3000); chassis.waitUntilDone(); scorer.move(127); pros::delay(600); scorer.move(0); // 3) back out and turn for the next cycle chassis.moveToPoint(24, 12, 2000, {.forwards = false}); chassis.waitUntilDone(); chassis.turnToHeading(180, 1000); chassis.waitUntilDone(); }
chassis.calibrate() in initialize(), or you moved the robot during IMU calibration. Call it once and keep the robot still until it finishes.diameter or offset. Re-measure with calipers/ruler. A flipped offset sign (left vs right, front vs back) mirrors or rotates your field β double-check the convention against the docs.kP too high or kD too low. Lateral and angular are tuned independently (Β§6).timeout and follow each motion with waitUntilDone(). Loosen the large-error exit condition so an unreachable target still times out.| Function | What it does | Notes |
|---|---|---|
| chassis.calibrate() | Calibrate IMU + start odometry | Once, in initialize(); keep still |
| setPose(x, y, ΞΈ) | Tell the robot where it starts | inches + degrees |
| getPose() | Read current (x, y, ΞΈ) | returns a Pose |
| moveToPoint(x, y, t) | Drive to a coordinate | heading is free |
| moveToPose(x, y, ΞΈ, t) | Drive to a full pose | boomerang controller |
| turnToHeading(ΞΈ, t) | Turn to an absolute heading | degrees |
| turnToPoint(x, y, t) | Turn to face a point | uses current pose |
| follow(path, look, t) | Pure-pursuit path follow | path from path.jerryio + ASSET() |
| waitUntilDone() | Block until the motion finishes | after each motion |
| waitUntil(dist) | Continue once dist is reached | mid-move triggers |
| cancelMotion() | Abort the current motion | async control |
{...} struct)| Param | Meaning |
|---|---|
| forwards | false = drive backward into the target |
| maxSpeed | cap on speed (0β127) |
| minSpeed | floor on speed β avoids stalling near the end |
| earlyExitRange | finish the motion once this close (smoother chaining) |
| lead | moveToPose only β how wide the boomerang arc is (0β1) |
waitUntil() and waitUntilDone() are synchronization primitives: they let two things happening at once (driving, and running an intake) coordinate cleanly. Same pattern as web servers, game loops, and robotics middleware.waitUntil() to start our intake 20 inches into a drive instead of stopping, repositioning, then driving again. Understanding blocking vs. non-blocking control let us overlap actions and cut about a second off our autonomous.”moveToPoint, without stopping the drive. Which call is correct?moveToPose vs moveToPoint for each step. Citing the specific LemLib calls your autonomous uses β in your own words β shows judges you understood the tool, not just imported it.