You control two zones. Everything else is automatic.
The match code template comes pre-built. You set your character's stats (Zone A) and program your opening move (Zone B). The engine handles the rest — finding the ball, aiming it at the goal, dribbling, and recovering from border hits and jams.
Set your four stats
Four numbers control everything: how fast you roam, how quickly your power fades, how sharply you arc and aim, and how hard you kick the ball.
CHECK line is your reminderProgram your opening move
After the match starts, your launch sequence drives the bot from its corner to behind the ball, pointed at the goal, before the main loop takes over.
// ← YOUR CODE HERElaunchActive lines alone — they pause border detection so your exact turns run uninterruptedFour stats. Must total 20. Fixed match time: 180 seconds.
The match always runs 180 seconds — match length is not controlled by stats. What changes is how fast your bot moves, how fast it slows down, how sharply it aims, and how hard it hits.
motorPower = SPEED × 100 / 20 // starting cruise speed (SPEED 6 → 30, SPEED 10 → 50)
chargePower = min(POWER², 100) // POWER 3 → 9, POWER 8 → 64, POWER 10 → 100 — caps at 10!
FOLLOW_THROUGH_MS = 120 + POWER × 35 // push time after contact (POWER 8 → 400ms)
turnRatio = 0.2 + TURNING × 0.02 // arc + aim sharpness
DECAY_RATE = 20 − ENDURANCE // how fast power drops over the match
minWheelSpeed = max(20, round(motorPower × 0.25)) // power floor (low-power "red" mode below this)
endurancePercent = 100 − DECAY_RATE × matchTime / 40, floored at 20%. ENDURANCE 4 (DECAY_RATE 16) is down to ~28% power by minute 3; ENDURANCE 17 (DECAY_RATE 3) barely fades.
chargePower = min(POWER², 100). POWER 8 → 64, POWER 9 → 81, POWER 10 → 100. Above 10 is wasted (the kick is already maxed), so the cap is 10. The other three are linear, so they run all the way to 17.
One setup, one start, two safety buttons.
Press 1 — Set up & calibrate
From idle: runs computeStats, then calibrates the border threshold (show White floor, then Black tape) and the goal heading (G — point at the goal). Shows the build bars + a Target icon when ready.
Press 2 — Start the match
When "ready", starts the 180-second match: resets state, runs launchSequence(), then enters matchLoop().
Soft reset
During a match, sets resetRequested = true. The loop clears ball/border state and re-centers behavior without ending the match.
Emergency stop
Sets matchRunning = false, stops all motors immediately, and shows an X. Every loop checks matchRunning and bails.
What happens from start to finish
How each section works
let SPEED = 6
let TURNING = 2
let ENDURANCE = 4
let POWER = 8
The four numbers you choose. The example above is the "Sniper" build: SPEED 6 + TURNING 2 + ENDURANCE 4 + POWER 8 = 20. Replace them with your character's stats. Press A and the on-screen budget reminds you the four must total exactly 20.
SPEED (1–17) — starting cruise speed. motorPower = SPEED × 100 / 20. SPEED 6 = 30, SPEED 17 = 85.ENDURANCE (1–17) — decay resistance. DECAY_RATE = 20 − ENDURANCE. Higher = stays fast longer. Does not change match length.TURNING (1–17) — arc shape and aim speed. Higher = tighter arcs and faster, more-persistent aiming when it locks onto the ball.POWER (1–10) — kick force (POWER²) and follow-through time. Capped at 10 because POWER² already hits the 100 max there.
let matchRunning = false
let readyToLaunch = false
let goalHeading = 0
let ownGoalHeading = 0
let matchStartTime = 0
function computeStats() {}
function calibrateThreshold() {}
function calibrateGoalHeading() {}
function showStatBars() {}
function launchSequence() {}
function matchLoop() {}
input.onButtonPressed(Button.A, function () {
if (!matchRunning && !readyToLaunch) {
computeStats()
calibrateThreshold()
calibrateGoalHeading()
ownGoalHeading = (goalHeading + 180) % 360
readyToLaunch = true
showStatBars()
basic.showIcon(IconNames.Target)
} else if (readyToLaunch) {
readyToLaunch = false
matchStartTime = input.runningTime()
matchRunning = true
launchSequence()
matchLoop()
}
})
Button A has two jobs. The first press calibrates; the second press starts the match.
computeStats() derives all working values, then calibrateThreshold() (show white floor, then black tape) finds the border cutoff and calibrateGoalHeading() records which way the goal is.ownGoalHeading (opposite the goal), shows the four-stat bars and a Target icon, and sets readyToLaunch = true.matchRunning = true, runs launchSequence(), then matchLoop() until 180 seconds elapse.
let SPEED = 0
let ENDURANCE = 0
let TURNING = 0
let POWER = 0
let motorPower = 0
let currentPower = 0
let chargePower = 0
let FOLLOW_THROUGH_MS = 0
let totalArcTime = 0
let BASE_SEGMENT_MS = 0
let thisDuration = 0
let turnRatio = 0
let DECAY_RATE = 0
let minWheelSpeed = 0
let lowPowerThreshold = 0
function computeStats () {
motorPower = SPEED * 100 / 20
currentPower = motorPower
chargePower = Math.min(POWER * POWER, 100)
FOLLOW_THROUGH_MS = 120 + POWER * 35
totalArcTime = BASE_SEGMENT_MS * 12
thisDuration = Math.min(Math.round(totalArcTime / TURNING), 600)
turnRatio = 0.2 + TURNING * 0.02
DECAY_RATE = 20 - ENDURANCE
minWheelSpeed = Math.max(20, Math.round(motorPower * 0.25))
lowPowerThreshold = minWheelSpeed
}
Converts your four stats into the numbers the engine actually uses. Runs once on the first A press, before calibration.
motorPower = SPEED × 100 / 20 — starting motor speed. currentPower starts here and decays during the match.chargePower = min(POWER², 100) — kick force. POWER 8 → 64, POWER 10 → 100 (the cap).FOLLOW_THROUGH_MS = 120 + POWER × 35 — push time after contact. POWER 8 → 400ms (the engine caps the push at 500ms).thisDuration = min((BASE_SEGMENT_MS × 12) / TURNING, 600) — each roam arc's length. BASE_SEGMENT_MS is 300, so totalArcTime = 3600ms.turnRatio = 0.2 + TURNING × 0.02 — arc sharpness (the roam jitters this between 0.10 and 0.45).minWheelSpeed = max(20, round(motorPower × 0.25)) — the real power floor. When currentPower decays to this, the bot enters low-power ("red") mode and plays conservatively.
let launchActive = false
let motorPower = 0
let currentPower = 0
let matchRunning = false
let ballDetected = false
let LAUNCH_APPROACH_MS = 0
function motorStop() {}
function checkBorder() { return 0 }
function handleBorder() {}
function driveUntilBall(ms: number): boolean { return false }
function chargeAndFollowThrough() {}
function launchSequence () {
launchActive = true
let powerScale = 30 / Math.max(1, motorPower)
// ← YOUR CODE HERE — example: a diagonal onto the ball→goal axis
CutebotPro.trolleySteering(CutebotProTurn.LeftInPlace, 35)
basic.pause(300); motorStop(); basic.pause(150)
CutebotPro.pwmCruiseControl(currentPower, currentPower)
basic.pause(Math.round(2520 * powerScale))
motorStop(); basic.pause(150)
CutebotPro.trolleySteering(CutebotProTurn.RightInPlace, 35)
basic.pause(300)
// ── LOCKED below — do not change ──
motorStop()
launchActive = false
if (checkBorder() > 0) { handleBorder(); return }
driveUntilBall(Math.round(LAUNCH_APPROACH_MS * powerScale))
ballDetected = true
launchActive = true
chargeAndFollowThrough()
launchActive = false
}
Your opening move. Border detection is paused (launchActive = true) so your exact turns and drives run start-to-finish. The example is the "Sniper" solution's diagonal: angle onto the ball→goal axis, drive, re-square to the goal. Below the LOCKED banner the engine drives the last bit to the ball and kicks it through — straight, with no compass curve (the launch is dead-reckoned).
runBlockCnt(n) drives exactly n × 27 cm regardless of speed — best for a planned path. Time-based driving uses pwmCruiseControl + pause(ms × powerScale); powerScale evens out fast vs. slow bots.trolleySteering(LeftInPlace / RightInPlace, degrees). Equal-and-opposite turns cancel, so a "turn out, drive, turn back" lands you re-squared to the goal.launchActive or the LOCKED block. They re-enable border safety and run the unconditional kick. If the ball isn't there, it's just a harmless drive at the goal.
let matchRunning = false
let matchTime = 0
let matchStartTime = 0
let motorPower = 0
let SPEED = 0
let DECAY_RATE = 0
let endurancePercent = 0
let enduranceFactor = 0
let currentPower = 0
let minWheelSpeed = 0
let lowPowerThreshold = 0
let lowPowerActive = false
let ballDetected = false
let roamCounter = 0
let lostScans = 0
let TURNING = 0
let REPOSITION_AFTER_SCANS = 0
function handleReset() {}
function endMatch() {}
function tryAimThenCharge() {}
function lookForBall() {}
function repositionHome() {}
function roamStep() {}
function checkBorder() { return 0 }
function handleBorder() {}
function matchLoop () {
while (matchRunning) {
handleReset()
matchTime = (input.runningTime() - matchStartTime) / 1000
if (matchTime >= 180) { endMatch(); break }
motorPower = SPEED * 100 / 20
endurancePercent = 100 - DECAY_RATE * matchTime / 40
enduranceFactor = Math.max(endurancePercent / 100, 0.2)
currentPower = Math.max(Math.round(motorPower * enduranceFactor), minWheelSpeed)
lowPowerActive = currentPower <= lowPowerThreshold
if (ballDetected) {
tryAimThenCharge()
continue
}
let scanLimit = lowPowerActive ? 2 : Math.max(3, 3 + Math.round(TURNING / 2))
if (roamCounter >= scanLimit) {
roamCounter = 0
lookForBall()
if (!ballDetected) {
lostScans += 1
if (lostScans >= REPOSITION_AFTER_SCANS && !lowPowerActive) {
lostScans = 0
repositionHome()
}
}
}
if (ballDetected) { tryAimThenCharge(); continue }
roamStep()
}
}
The heart of the engine. Runs until matchTime ≥ 180. Each pass recalculates power from the endurance curve, then picks one action by priority.
endurancePercent = 100 − DECAY_RATE × matchTime / 40 (floored at 20%) scales currentPower, floored at minWheelSpeed. When it hits that floor, lowPowerActive turns on and the bot plays conservatively.tryAimThenCharge() — center the ball, then dribble it toward the goal. (Top priority.)scanLimit roam segments, lookForBall() does a stationary sweep.repositionHome() drops the bot back to its own side and turns to look down the field (skipped on low power).roamStep() drives one goal-biased arc, watching for the ball and borders the whole time.
let matchRunning = false
let launchActive = false
let lastBorderPoll = 0
let lastPingTime = 0
let pingInterval = 0
let borderHandledInSeg = false
let ballDetected = false
let ballHeading = 0
let BORDER_THRESHOLD = 0
let BALL_DETECT_CM = 0
let BALL_SEE_CM = 0
function readTrackbit() {}
function borderSideFrom(t: number): number { return 0 }
function motorStop() {}
function handleBorder() {}
function confirmBall(stopped: boolean): boolean { return false }
function driveAndWatch (ms: number, lSpeed: number, rSpeed: number) {
let watchStart = input.runningTime()
let BORDER_POLL_MS = 6
while (input.runningTime() - watchStart < ms) {
if (!matchRunning) { return }
// ── Border (every 6ms): one read serves hard + soft ──
if (input.runningTime() - lastBorderPoll >= BORDER_POLL_MS) {
lastBorderPoll = input.runningTime()
readTrackbit()
if (!launchActive && borderSideFrom(BORDER_THRESHOLD) > 0) {
CutebotPro.pwmCruiseControl(-60, -60)
basic.pause(70); motorStop()
borderHandledInSeg = true
handleBorder()
return
}
}
// ── Ball detection while driving ──
if (input.runningTime() - lastPingTime > pingInterval) {
lastPingTime = input.runningTime()
let dist = CutebotPro.ultrasonic(SonarUnit.Centimeters)
if (dist > 5 && dist < BALL_DETECT_CM) {
motorStop()
if (confirmBall(true)) { ballHeading = 99; ballDetected = true; return }
} else if (dist > 5 && dist < BALL_SEE_CM) {
// saw it at range — drive STRAIGHT at it to close the gap
CutebotPro.pwmCruiseControl(rSpeed, rSpeed)
}
}
basic.pause(5)
}
}
Drives at (lSpeed, rSpeed) for ms while watching for two things. It returns early if it handles a border or finds the ball.
-60 for 70ms), pulls the sensors back off the line, and runs handleBorder(). A second read right after each sonar ping is the failsafe for fast crossings.BALL_DETECT_CM → stop, confirm, set ballDetected, hand off to aiming.BALL_SEE_CM while driving → stop arcing and drive straight at it to close the gap. This is how the bot finds the ball during normal roaming, not just during a scan.launchActive). Everywhere else it runs on a tight 6ms cadence — border safety outranks everything, because the one thing worse than missing the ball is driving off the field.
let matchRunning = false
let scanPower = 0
let ballDetected = false
let ballHeading = 0
let BALL_SCAN_CM = 0
function checkBorder() { return 0 }
function handleBorder(d: number, s: boolean) {}
function confirmBallScan(): boolean { return false }
function sweepLeg (turnLeft: boolean, ms: number, foundHeading: number): number {
let STEP_MS = 90
let elapsed = 0
while (elapsed < ms) {
if (!matchRunning) { return 2 }
if (checkBorder() > 0) { handleBorder(0, false); return 2 }
// rotate one small step...
let burst = Math.min(STEP_MS, ms - elapsed)
if (turnLeft) { CutebotPro.pwmCruiseControl(0 - scanPower, scanPower) }
else { CutebotPro.pwmCruiseControl(scanPower, 0 - scanPower) }
basic.pause(burst); elapsed += burst
// ...then STOP and look (a still beam actually sees the ball)
CutebotPro.stopImmediately(CutebotProMotors.ALL)
basic.pause(30)
let d = CutebotPro.ultrasonic(SonarUnit.Centimeters)
if (d > 5 && d < BALL_SCAN_CM) {
if (confirmBallScan()) { ballHeading = foundHeading; ballDetected = true; return 1 }
}
}
return 0
}
When roaming hasn't turned up the ball, lookForBall() runs a step-scan: four legs (left, back, right, back) biased toward the goal side. Each leg rotates a small step, stops, then pings.
closeOnBall() drives straight in until it's close enough to commit the shot.1 = found, 2 = aborted (border/stop), 0 = nothing this leg.
let matchRunning = false
let launchActive = false
let goalHeadingValid = false
let goalHeading = 0
let ballDetected = false
let dynamicCharge = 0
let actualFollowThrough = 0
let CONTACT_CM = 0
let GOAL_AIM_TOL = 0
let BALL_DETECT_CM = 0
function stableHeadingBlocking(): number { return -1 }
function checkBorder() { return 0 }
function handleBorder() {}
function alignBeforeCharge(): boolean { return false }
function tryAimThenCharge () {
if (alignBeforeCharge()) {
chargeAndFollowThrough()
} else {
ballDetected = false
}
}
function chargeAndFollowThrough () {
// Goal-seeking dribble: bias one wheel so the IN-CONTACT push curves
// toward the goal heading. Skipped during the dead-reckoned launch.
let lPush = dynamicCharge
let rPush = dynamicCharge
if (!launchActive && goalHeadingValid) {
let gh = stableHeadingBlocking()
if (gh >= 0) {
let ge = goalHeading - gh
while (ge > 180) { ge -= 360 }
while (ge < -180) { ge += 360 }
let bias = Math.round(dynamicCharge * 0.45)
if (ge > GOAL_AIM_TOL) { rPush = Math.max(dynamicCharge - bias, 18) }
else if (ge < 0 - GOAL_AIM_TOL) { lPush = Math.max(dynamicCharge - bias, 18) }
}
}
while (ballDetected) {
if (!matchRunning) { return }
if (checkBorder() > 0) { handleBorder(); ballDetected = false; return }
let currentDist = CutebotPro.ultrasonic(SonarUnit.Centimeters)
// In contact -> curve toward goal; still approaching -> drive straight in
if (currentDist > 5 && currentDist < CONTACT_CM) {
CutebotPro.pwmCruiseControl(lPush, rPush)
} else {
CutebotPro.pwmCruiseControl(dynamicCharge, dynamicCharge)
}
basic.pause(actualFollowThrough)
// ball still ahead? keep dribbling. lost it? stop and rescan.
let f = CutebotPro.ultrasonic(SonarUnit.Centimeters)
ballDetected = (f > 5 && f < BALL_DETECT_CM)
}
}
First alignBeforeCharge() centers the ball (and nudges out of the own-goal cone). Then the bot pushes the ball — and curves the push toward the goal while it's in contact.
CONTACT_CM = 15cm), it reads the compass and eases off one wheel so the ball curves toward the goal heading. Already pointed at the goal → straight kick.turnToHeading (and these two branches) is wired for the opposite chassis and needs swapping.
let matchRunning = false
let goalHeadingValid = false
let lowPowerActive = false
let ownGoalHeading = 0
let goalHeading = 0
let currentPower = 0
let REPOSITION_MS = 0
function turnToHeading(target: number, tol: number) {}
function checkBorder() { return 0 }
function motorStop() {}
function lookForBall() {}
function repositionHome () {
if (!matchRunning || !goalHeadingValid || lowPowerActive) { return }
// 1. Face our own side.
turnToHeading(ownGoalHeading, 15)
// 2. Drive home, ignoring the ball on the way (it'd be on the wrong side of us).
let t0 = input.runningTime()
CutebotPro.pwmCruiseControl(currentPower, currentPower)
while (input.runningTime() - t0 < REPOSITION_MS) {
if (checkBorder() > 0) { motorStop(); break }
basic.pause(8)
}
motorStop()
// 3. Turn to look DOWN THE FIELD (toward their goal) and scan.
turnToHeading(goalHeading, 15)
lookForBall()
}
When scans keep coming up empty, the bot stops loitering. It returns to its own end, then turns to face the opponent's goal — where the ball usually is — and scans from there.
turnToHeading) to face its own goal, then the opponent's. Needs the goal-heading calibration to work.
let tb1 = 0
let tb2 = 0
let tb3 = 0
let tb4 = 0
function readTrackbit () {
CutebotPro.trackbitStateValue()
tb1 = CutebotPro.trackbitgetGray(TrackbitChannel.One)
tb2 = CutebotPro.trackbitgetGray(TrackbitChannel.Two)
tb3 = CutebotPro.trackbitgetGray(TrackbitChannel.Three)
tb4 = CutebotPro.trackbitgetGray(TrackbitChannel.Four)
}
function borderSideFrom (threshold: number): number {
if (tb1 > threshold && tb4 > threshold) { return 4 }
if (tb1 > threshold) { return 1 }
if (tb4 > threshold) { return 2 }
if (tb2 > threshold || tb3 > threshold) { return 3 }
return 0
}
readTrackbit() samples all four line sensors once into shared temps; borderSideFrom(threshold) reads those temps and reports which side hit the border. Splitting them means the engine takes one I2C read and reuses it for both the hard and soft thresholds.
1 = left-front (ch1) → turn right. 2 = right-front (ch4) → turn left.3 = center (ch2/ch3) → head-on; 4 = both outer → square-on. Both trigger a bigger backup + turn in handleBorder.0 = all clear. The BORDER_THRESHOLD is set at calibration (white floor vs. black tape) and adapts slightly as the floor reading drifts.
let matchRunning = false
let lowPowerActive = false
let SPEED = 0
let borderSide = 0
let backupPower = 0
let backupTime = 0
let lastTurnWasLeft = false
function hardStop() {}
function turnAwayFrom(side: number, angle: number) {}
function checkBorder() { return 0 }
function lookForBall() {}
function handleBorder (depth: number = 0, scanAfter: boolean = true) {
if (!matchRunning) { return }
hardStop()
let speedExtra = Math.max(SPEED - 4, 0)
backupPower = lowPowerActive ? -25 : (0 - 40 - speedExtra * 5)
backupTime = lowPowerActive ? 130 : (250 + speedExtra * 20)
let attempts = 0
while (attempts < 3) {
attempts += 1
let side = borderSide
borderSide = 0
CutebotPro.pwmCruiseControl(backupPower, backupPower)
basic.pause(backupTime)
CutebotPro.stopImmediately(CutebotProMotors.ALL)
let angle = (side == 1 || side == 2) ? 115 : 130
turnAwayFrom(side, angle)
CutebotPro.pwmCruiseControl(currentPower, currentPower)
basic.pause(300)
CutebotPro.stopImmediately(CutebotProMotors.ALL)
if (checkBorder() == 0) { break }
}
if (scanAfter) { lookForBall() }
}
Called whenever a border is detected. Active-brakes, backs up (scaled by speed), turns away from the triggered side, nudges forward, and re-checks — up to three times until clear. On low power the moves shrink so the bot can't reverse off the opposite edge.
hardStop() kills momentum, then it pins its rough field position from the compass heading at the wall (used by the own-goal cone).turnAwayFrom(side, angle) — sides 1/2 turn deterministically; head-on (3/4) alternates left/right so it doesn't re-hit the same corner.unstick) handles wheels that spin without moving — a randomized reverse + big turn so two locked bots break apart.Every variable and what it does
Zone A — You Set These
1–17
1–17
1–17
1–10
Auto-Computed — Don't Modify
Auto
Auto
Auto
Auto
Auto
Auto
Auto
Auto
System Constants — Tune With Care
40
45
50
15
calibrated
300
18
3
Getting more out of the match code
Calibrate every time
Run the W/B border calibration and the G goal-heading step on the actual field each match. The bot drives off the field if the threshold is wrong, and dribbles the wrong way if the goal heading is off.
ENDURANCE ≠ match length
All matches run 180 seconds. ENDURANCE controls how fast your speed drops. ENDURANCE 4 is down to ~28% by minute 3; ENDURANCE 17 barely fades. High SPEED only helps if you can still use it late.
POWER squares, then caps
chargePower = POWER². POWER 8 → 64, POWER 10 → 100. There's no point going above 10. Spend extra points on the linear stats, which run to 17.
Launch is geometry
Finish your launch behind the ball, pointed at the goal. Use runBlockCnt for exact distances and a Pythagorean diagonal to cut the corner. Test it before a real match.
Verify the turn direction
The dribble and drop-back use the compass. If your bot ever curves toward the wrong net, swap the two branches in turnToHeading (and the dribble) for your chassis — test it once standing still.
Total must equal 20
Power 1–10, the rest 1–17, all four summing to exactly 20. Try builds in the simulator first — a Sniper (high POWER) plays very differently from a Speedster or a Tank.