← Resources gameCode-SOLUTION.ts Download .ts

Teacher copy · Velocity Arena

Match Code — Reference

The full match program with a worked launch sequence in the STUDENT ZONE. Everything below the LOCKED line runs the bot's match behavior and should not be edited. Use the launch section to show a team what a finished sequence looks like.

Do not project this on the board. This is the answer. Read it yourself, then go help the team with a question — do not show them the screen.
1574 lines · source file gameCode-SOLUTION.ts
/**
 * ============================================================
 * CUTEBOT PRO SOCCER  —  SOLUTION (filled-in reference, not the blank template)
 * 8' x 4' Field | White Floor | Black Border Tape
 * ============================================================
 *
 * HOW A MATCH RUNS
 *   1. Press A (idle)   -> compute stats, calibrate border threshold (W/B),
 *                          calibrate the goal heading (G), show "ready" target.
 *   2. Press A (ready)  -> start the match: run the scripted launch kick, then
 *                          enter the main loop.
 *   3. matchLoop()      -> repeatedly: update endurance, chase/aim/kick the ball
 *                          if we have it, otherwise scan + roam to find it.
 *   B   = soft reset (re-center behavior mid-match)
 *   A+B = stop the match.
 *
 * STUDENT STATS (total 20 · Power 1-10 · Speed/Endurance/Turning 1-17) and all tunable distances/thresholds live
 * at the very bottom in the INITIALIZATION block.
 *
 * NONNEGOTIABLE STAT FORMULAS (do not change - students compute these by hand):
 *   motorPower        = SPEED * 100 / 20
 *   turnRatio         = 0.2 + TURNING * 0.02
 *   chargePower       = min(POWER * POWER, 100)
 *   FOLLOW_THROUGH_MS = 120 + POWER * 35
 *   DECAY_RATE        = 20 - ENDURANCE
 *   minWheelSpeed     = max(WHEEL_FLOOR, round(motorPower * 0.25))   // WHEEL_FLOOR = 15
 *   lowPowerThreshold = minWheelSpeed
 *   endurancePercent  = 100 - DECAY_RATE * matchTime / 40
 *
 * SPEED FLOOR: minWheelSpeed is the real floor = max(WHEEL_FLOOR, round(motorPower * 0.25)).
 * For low-to-mid SPEED the WHEEL_FLOOR term wins, so currentPower bottoms out at WHEEL_FLOOR
 * (currently 15) and lowPowerActive (red) fires there. Lower WHEEL_FLOOR = bigger drainable
 * range (ENDURANCE matters more); too low and the bot stalls when tired.
 * ============================================================
 */

// ============================================================
// MOTOR STOP HELPER
// ============================================================

// Stop the motors reliably. Double-stops because the motor driver can latch a
// previous command when the I2C bus is busy; the extra stop clears that.
function motorStop() {
    CutebotPro.stopImmediately(CutebotProMotors.ALL)
    basic.pause(10)
    CutebotPro.pwmCruiseControl(0, 0)
    basic.pause(10)
    CutebotPro.stopImmediately(CutebotProMotors.ALL)
}

// ============================================================
// TRACKBIT / BORDER PRIMITIVES  (single source of truth)
// ============================================================

// Take ONE I2C sample of the four line sensors into module-level temps.
// Every border interpretation below reads those temps (no extra I2C traffic).
function readTrackbit() {
    CutebotPro.trackbitStateValue()
    tb1 = CutebotPro.trackbitgetGray(TrackbitChannel.One)
    tb2 = CutebotPro.trackbitgetGray(TrackbitChannel.Two)
    tb3 = CutebotPro.trackbitgetGray(TrackbitChannel.Three)
    tb4 = CutebotPro.trackbitgetGray(TrackbitChannel.Four)
}

// Classify the LAST readTrackbit() sample against `threshold`. Returns which side
// of the border is triggered:
//   1 = left-front (ch1)   2 = right-front (ch4)
//   3 = center (ch2/ch3)   4 = both outer (head-on)   0 = clear
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
}

// Average of the four line sensors (used by calibration + adaptive floor tracking).
function trackbitFloorAvg(): number {
    return Math.round((tb1 + tb2 + tb3 + tb4) / 4)
}

// Public border check for use OUTSIDE driveAndWatch. Takes a fresh sample each
// call. Returns the border side (see borderSideFrom) or 0. Always 0 during the
// launch kick (launchActive) so the goal-line tape can't abort it.
function checkBorder(): number {
    if (launchActive) { return 0 }
    readTrackbit()
    let side = borderSideFrom(BORDER_THRESHOLD)
    if (side > 0) { borderSide = side }
    return side
}

// Turn away from a detected border side by `angle` degrees, in place.
// Sides 1/2 are deterministic (turn away from that side); head-on hits (3/4)
// alternate left/right so we don't oscillate back into the same corner.
function turnAwayFrom(side: number, angle: number) {
    if (side == 1) {
        CutebotPro.trolleySteering(CutebotProTurn.RightInPlace, angle)
        lastTurnWasLeft = false
    } else if (side == 2) {
        CutebotPro.trolleySteering(CutebotProTurn.LeftInPlace, angle)
        lastTurnWasLeft = true
    } else if (lastTurnWasLeft) {
        CutebotPro.trolleySteering(CutebotProTurn.RightInPlace, angle)
        lastTurnWasLeft = false
    } else {
        CutebotPro.trolleySteering(CutebotProTurn.LeftInPlace, angle)
        lastTurnWasLeft = true
    }
}

// ============================================================
// DRIVE UNTIL BALL  (border-aware; sonar closes the final gap)
// ============================================================

// Drive straight forward for up to `maxMs`, stopping early when the sonar
// confirms a ball within BALL_DETECT_CM. Returns true if a ball was captured,
// false on timeout / border / match end.
function driveUntilBall(maxMs: number): boolean {
    let t0 = input.runningTime()
    CutebotPro.pwmCruiseControl(currentPower, currentPower)
    while (input.runningTime() - t0 < maxMs) {
        if (!matchRunning) { return false }
        if (matchExpired()) { endMatch(); return false }
        if (checkBorder() > 0) { motorStop(); handleBorder(); return false }

        let d = CutebotPro.ultrasonic(SonarUnit.Centimeters)
        if (d > 4 && d < BALL_DETECT_CM) {
            motorStop()
            if (confirmBall(true)) { return true }
            CutebotPro.pwmCruiseControl(currentPower, currentPower)   // false alarm, keep going
        }
        basic.pause(5)
    }
    motorStop()
    return false
}

/* ============================================================================
 *  ★ STUDENT ZONE B — LAUNCH SEQUENCE ★
 * ============================================================================
 *  This runs ONCE when the match starts. Program your bot's opening move.
 *
 *  THE BIG IDEA -- line up THREE things:   YOUR BOT  ->  THE BALL  ->  THE GOAL
 *  Your kick sends the ball the way the bot is FACING, so finish your launch
 *  BEHIND the ball and pointed straight at the goal.
 *
 *  ── DRIVE BY DISTANCE (best for geometry) ──
 *     CutebotPro.runBlockCnt(n)     drives exactly n x 27 cm, then stops.
 *     Measure a distance in cm, divide by 27 -> that's your block count.
 *     Same distance on every bot (fast or slow), so your math stays true.
 *
 *  ── DRIVE BY TIME (approximate) ──
 *     CutebotPro.pwmCruiseControl(currentPower, currentPower)
 *     basic.pause(Math.round(900 * powerScale))   // powerScale evens out speed
 *
 *  ── TURN IN PLACE ──
 *     CutebotPro.trolleySteering(CutebotProTurn.LeftInPlace,  45)   // spin left 45 deg
 *     CutebotPro.trolleySteering(CutebotProTurn.RightInPlace, 90)   // spin right 90 deg
 *
 *  ── STOP / WAIT ──
 *     CutebotPro.stopImmediately(CutebotProMotors.ALL)
 *     basic.pause(500)                                              // wait 0.5 s
 *
 *  ── PICK A STRATEGY (use geometry!) ──
 *   TIER 1  Straight shot: set up so the ball is already between you and the
 *           goal, then drive straight.   (shortest path is a straight line)
 *   TIER 2  Diagonal: ball is forward AND to the side. Turn toward it, drive the
 *           diagonal, then turn back the SAME angle to re-face the goal.
 *           (right triangle:  a^2 + b^2 = c^2  -> drive the hypotenuse c)
 *   TIER 3  Approach from behind: drive to the far side of the ball (opposite the
 *           goal), then come straight through it.   (bot, ball, goal on ONE line)
 *
 *  ── WORKED EXAMPLE (ball 81 cm forward, 81 cm left -> 45 deg, c ~= 115 cm) ──
 *     CutebotPro.trolleySteering(CutebotProTurn.LeftInPlace, 45)
 *     basic.pause(300)
 *     CutebotPro.stopImmediately(CutebotProMotors.ALL); basic.pause(150)
 *     CutebotPro.runBlockCnt(4)          // 115 cm / 27 ~= 4 blocks
 *     basic.pause(150)
 *     CutebotPro.trolleySteering(CutebotProTurn.RightInPlace, 45)
 *     basic.pause(150)
 * ============================================================================
 */
function launchSequence() {
    // Border detection is paused here so your exact turns/drives run uninterrupted.
    launchActive = true                            // <- DO NOT DELETE
    let powerScale = 30 / Math.max(1, motorPower)  // <- DO NOT DELETE (only used if you drive by TIME)

    // ========================================================================
    //   ★ WRITE YOUR LAUNCH HERE ★
    //   Drive from your corner to BEHIND the ball, pointed at the goal.
    // ========================================================================

    // SOLUTION launch -- Diagonal (right-triangle shortcut):
    // shimmy LEFT onto the ball->goal axis, drive the diagonal, then re-square
    // RIGHT by the SAME angle so we end up facing straight down the field.
    CutebotPro.trolleySteering(CutebotProTurn.LeftInPlace, 35)
    basic.pause(300)
    CutebotPro.stopImmediately(CutebotProMotors.ALL)
    basic.pause(150)

    CutebotPro.pwmCruiseControl(currentPower, currentPower)
    basic.pause(Math.round(2520 * powerScale))    // diagonal drive (time-based)
    CutebotPro.stopImmediately(CutebotProMotors.ALL)
    basic.pause(150)

    CutebotPro.trolleySteering(CutebotProTurn.RightInPlace, 35)
    basic.pause(300)

    // ========================================================================
    //   LOCKED -- do not change anything below this line.
    //   The bot turns border safety back ON, drives the last bit to the ball,
    //   and kicks it straight through the goal.
    // ========================================================================
    motorStop()
    launchActive = false                           // border safety back ON
    basic.pause(30)
    if (checkBorder() > 0) { handleBorder(); return }
    if (!matchRunning) { return }

    // Roll forward to close the final gap to the ball (best-effort).
    driveUntilBall(Math.round(LAUNCH_APPROACH_MS * powerScale))
    if (!matchRunning) { return }

    // Commit the kick straight through the goal.
    ballDetected = true
    launchActive = true                            // goal-line tape must NOT abort the kick
    chargeAndFollowThrough()
    launchActive = false

    if (!matchRunning) { return }
    motorStop(); basic.pause(60)
    if (checkBorder() > 0) { handleBorder() }
}

// ============================================================
// THRESHOLD CALIBRATION  (press A -> step through W then B)
// ============================================================
// Samples the white floor and the black tape, then sets BORDER_THRESHOLD 55% of
// the way between them. Shows F(floor) / T(tape) / TH(threshold) at the end.
function calibrateThreshold() {
    const NUM_SAMPLES = 30
    const SAMPLE_MS = 40

    // ---- White floor: press B to capture ----
    strip.showColor(neopixel.rgb(0, 0, 255))
    basic.showString("W")
    while (!input.buttonIsPressed(Button.B)) { basic.pause(20) }
    basic.pause(80)
    while (input.buttonIsPressed(Button.B)) { basic.pause(20) }
    basic.pause(100)
    strip.showColor(neopixel.rgb(0, 0, 128))
    basic.clearScreen(); basic.showIcon(IconNames.SmallSquare)

    let floorTotal = 0
    for (let i = 0; i < NUM_SAMPLES; i++) {
        readTrackbit()
        floorTotal += trackbitFloorAvg()
        basic.pause(SAMPLE_MS)
    }
    let floorAvg = Math.round(floorTotal / NUM_SAMPLES)

    strip.showColor(neopixel.rgb(0, 0, 0)); basic.pause(150)
    strip.showColor(neopixel.rgb(0, 0, 255)); basic.pause(150)
    strip.showColor(neopixel.rgb(0, 0, 0)); basic.pause(150)

    // ---- Black tape: press B to capture ----
    strip.showColor(neopixel.rgb(0, 255, 0))
    basic.showString("B")
    while (!input.buttonIsPressed(Button.B)) { basic.pause(20) }
    basic.pause(80)
    while (input.buttonIsPressed(Button.B)) { basic.pause(20) }
    basic.pause(100)
    strip.showColor(neopixel.rgb(0, 128, 0))
    basic.clearScreen(); basic.showIcon(IconNames.SmallSquare)

    let tapeTotal = 0
    for (let i = 0; i < NUM_SAMPLES; i++) {
        readTrackbit()
        tapeTotal += trackbitFloorAvg()
        basic.pause(SAMPLE_MS)
    }
    let tapeAvg = Math.round(tapeTotal / NUM_SAMPLES)

    // Threshold sits 55% from floor to tape. Remember the margin above the floor
    // so the adaptive tracker (in driveAndWatch) can preserve it as the floor drifts.
    BORDER_THRESHOLD = Math.round(floorAvg + (tapeAvg - floorAvg) * 0.55)
    BORDER_THRESHOLD = Math.max(80, Math.min(220, BORDER_THRESHOLD))
    borderMargin = BORDER_THRESHOLD - floorAvg
    floorBaseline = floorAvg

    strip.showColor(neopixel.rgb(255, 255, 255)); basic.pause(400)
    strip.showColor(neopixel.rgb(0, 0, 0))
    basic.showString("F" + floorAvg); basic.pause(100)
    basic.showString("T" + tapeAvg); basic.pause(100)
    basic.showString("TH" + BORDER_THRESHOLD); basic.pause(200)
    basic.clearScreen()
}

// ============================================================
// GOAL HEADING CALIBRATION  (press A -> point at goal, press B)
// ============================================================
// Captures the compass heading toward the opponent's goal. Used for goal-biased
// roaming, the goal-seeking dribble, repositioning, and own-goal avoidance.
function calibrateGoalHeading() {
    strip.showColor(neopixel.rgb(255, 0, 255))
    basic.showString("G")
    while (input.buttonIsPressed(Button.B)) { basic.pause(20) }
    basic.pause(100)
    while (!input.buttonIsPressed(Button.B)) { basic.pause(20) }
    basic.pause(80)
    while (input.buttonIsPressed(Button.B)) { basic.pause(20) }
    basic.pause(100)
    basic.pause(300)

    let hSum = 0
    for (let i = 0; i < 10; i++) { hSum += input.compassHeading(); basic.pause(30) }
    goalHeading = Math.round(hSum / 10) % 360
    goalHeadingValid = true

    strip.showColor(neopixel.rgb(255, 255, 255)); basic.pause(400)
    strip.showColor(neopixel.rgb(0, 0, 0))
    basic.showString("G" + goalHeading); basic.pause(300)
    basic.clearScreen()
}

// ============================================================
// BUTTON HANDLERS
// ============================================================

// A: first press = set up & calibrate (-> "ready"); second press = start match.
input.onButtonPressed(Button.A, function () {
    if (!matchRunning && !readyToLaunch) {
        // ---- Setup / calibration pass ----
        computeStats()
        calibrateThreshold()
        calibrateGoalHeading()
        ownGoalHeading = (goalHeading + 180) % 360
        readyToLaunch = true
        // showBuildArchetype()      // optional: scroll the build name (SPEEDSTER / SNIPER / TANK / NINJA)

        if (SPEED + ENDURANCE + TURNING + POWER != 20) {
            basic.showString("ERR")
            basic.pause(1000)
            basic.clearScreen()
            return
        }
        showStatBars()               // quick 4-stat bar chart
        basic.pause(1200)
        basic.clearScreen()
        basic.showIcon(IconNames.Target)
    } else if (readyToLaunch) {
        // ---- Start the match ----
        readyToLaunch = false
        matchStartTime = input.runningTime()
        lastBaselineUpdate = input.runningTime()
        matchRunning = true
        ballDetected = false
        ballHeading = 0
        roamCounter = 0
        lostScans = 0
        lastCachedHeading = -1
        lastHeadingTime = 0
        sameDirectionCount = 0
        maxSameDirection = Math.randomRange(1, 2)
        lastTurnWasLeft = false
        resetRequested = false
        enduranceFactor = 1                       // full strength for the launch kick
        lowPowerActive = false
        homeProximity = 0.5                       // neutral until a wall pins our field end
        lastHomeUpdate = input.runningTime()
        basic.showIcon(IconNames.Yes); basic.pause(300); basic.clearScreen()
        launchSequence()
        matchLoop()
    }
})

// B: soft reset during a match (handled at the top of matchLoop).
input.onButtonPressed(Button.B, function () {
    if (matchRunning) {
        CutebotPro.stopImmediately(CutebotProMotors.ALL)
        CutebotPro.pwmCruiseControl(0, 0)
        resetRequested = true
    }
})

// A+B: hard stop the match.
input.onButtonPressed(Button.AB, function () {
    matchRunning = false
    ballDetected = false
    motorStop()
    basic.showLeds(`
        # . . . #
        . # . # .
        . . # . .
        . # . # .
        # . . . #
        `)
    basic.pause(500)
    basic.clearScreen()
})

// ============================================================
// HEADING (compass)
// ============================================================

// How long a cached heading stays valid, in ms. Slower bots can trust it longer.
function headingCacheTTL(): number {
    if (SPEED <= 4) { return 400 }
    if (SPEED <= 7) { return 200 }
    return 100
}

// Return a heading only if two reads 50ms apart agree (within tolerance); else -1.
// Uses a short-lived cache so we don't spam the compass while moving.
function stableHeading(): number {
    let now = input.runningTime()
    if (lastCachedHeading >= 0 && (now - lastHeadingTime) < headingCacheTTL()) {
        return lastCachedHeading
    }
    let h1 = input.compassHeading()
    basic.pause(50)
    let h2 = input.compassHeading()
    let diff = Math.abs(h1 - h2)
    if (diff > 180) { diff = 360 - diff }
    if (diff > 16) { lastCachedHeading = -1; return -1 }   // loosened: availability > precision
    lastCachedHeading = Math.round((h1 + h2) / 2) % 360
    lastHeadingTime = now
    return lastCachedHeading
}

// Blocking heading read for use while STATIONARY (compass is most reliable then).
// Settles for vibration, forces fresh reads, retries a few times; -1 if it can't
// get a stable value.
function stableHeadingBlocking(): number {
    basic.pause(50)                  // let chassis vibration settle (it skews the magnetometer)
    for (let i = 0; i < 5; i++) {
        lastCachedHeading = -1
        let h = stableHeading()
        if (h >= 0) { return h }
        basic.pause(40)
    }
    return -1
}

// ============================================================
// MATCH CONTROL HELPERS
// ============================================================

// True once the 180-second match clock has run out.
function matchExpired(): boolean {
    return matchRunning && (input.runningTime() - matchStartTime) / 1000 >= 180
}

// End the match and stop the motors.
function endMatch() {
    matchRunning = false
    CutebotPro.stopImmediately(CutebotProMotors.ALL)
}

// Active brake: a brief reverse pulse (scaled by speed) then a hard stop, to kill
// momentum quickly without coasting.
function hardStop() {
    let brakeTime = 160 + Math.max(SPEED - 5, 0) * 35
    CutebotPro.pwmCruiseControl(-80, -80)
    basic.pause(brakeTime)
    CutebotPro.stopImmediately(CutebotProMotors.ALL)
}

// Soft reset (B button): clear ball/border state and flash white, without ending
// the match. Called at the top of matchLoop.
function handleReset() {
    if (!resetRequested) { return }
    resetRequested = false
    borderSide = 0
    ballDetected = false
    ballHeading = 0
    lowPowerActive = false
    roamCounter = 0
    sameDirectionCount = 0
    lastCachedHeading = -1
    lastHeadingTime = 0
    maxSameDirection = Math.randomRange(1, 2)
    CutebotPro.stopImmediately(CutebotProMotors.ALL)
    strip.showColor(neopixel.colors(NeoPixelColors.White))
    basic.pause(100)
    strip.clear()
}

// ============================================================
// ROAM STEP  (one search arc + checks)
// ============================================================
// Drives one randomized arc segment to cover ground, biased toward the opponent's
// half. Afterwards it checks the border and runs a jam test.
function roamStep() {
    // Jam baseline: read the heading while still. After the arc we re-read it; an
    // arc that produced ~no rotation means the wheels are free-spinning.
    let h0 = stableHeadingBlocking()

    // Goal-biased roam: ~70% of direction changes steer the arc toward the goal;
    // the rest stay random so the bot still explores.
    let gs = 0
    if (goalHeadingValid) {
        gs = goalSide()
    }

    if (sameDirectionCount >= maxSameDirection) {
        sameDirectionCount = 0
        maxSameDirection = Math.randomRange(1, 2)
        if (gs < 0 && Math.randomRange(0, 9) < 7) {
            lastTurnWasLeft = true          // goal is to the LEFT -> arc left
        } else if (gs > 0 && Math.randomRange(0, 9) < 7) {
            lastTurnWasLeft = false         // goal is to the RIGHT -> arc right
        } else {
            lastTurnWasLeft = !lastTurnWasLeft   // no goal info -> keep alternating
        }
    } else {
        sameDirectionCount += 1
    }

    // Arc sharpness (jittered turnRatio) and segment length (shorter when tired).
    let dynamicRatio = Math.max(0.10, Math.min(0.45,
        turnRatio + Math.randomRange(-8, 8) / 100))

    let speedDurationBonus = SPEED * 12
    let dynamicDuration = lowPowerActive
        ? Math.max(Math.min(thisDuration / 2 + Math.randomRange(-50, 50), 300), 150)
        : Math.max(Math.min(thisDuration + speedDurationBonus + Math.randomRange(-50, 50), 550), 150)

    // Wheel speeds for the arc; strip color shows turn direction (red = low power).
    let lSpeed: number
    let rSpeed: number
    if (lastTurnWasLeft) {
        lSpeed = Math.round(currentPower * (1 - dynamicRatio))
        rSpeed = Math.round(currentPower * (1 + dynamicRatio))
        strip.showColor(lowPowerActive ? neopixel.colors(NeoPixelColors.Red)
            : neopixel.colors(NeoPixelColors.Blue))
    } else {
        lSpeed = Math.round(currentPower * (1 + dynamicRatio))
        rSpeed = Math.round(currentPower * (1 - dynamicRatio))
        strip.showColor(lowPowerActive ? neopixel.colors(NeoPixelColors.Red)
            : neopixel.rgb(0, 255, 0))
    }

    lSpeed = Math.max(lSpeed, minWheelSpeed)
    rSpeed = Math.max(rSpeed, minWheelSpeed)
    if (lowPowerActive) {
        lSpeed = Math.min(lSpeed, currentPower)
        rSpeed = Math.min(rSpeed, currentPower)
    }

    // Drive the segment (driveAndWatch handles borders + ball detection mid-drive).
    borderHandledInSeg = false
    CutebotPro.pwmCruiseControl(lSpeed, rSpeed)
    driveAndWatch(dynamicDuration, lSpeed, rSpeed)

    if (!borderHandledInSeg && !ballDetected) {
        CutebotPro.pwmCruiseControl(-40, -40)   // brief active brake at segment end
        basic.pause(40)
    }
    motorStop()

    if (!ballDetected && matchRunning) {
        basic.pause(40)
        if (checkBorder() > 0) {
            handleBorder()
            if (ballDetected) { return }
        } else {
            roamCounter += 1

            // JAM CHECK (wheels free-spinning in place): the arc should have rotated
            // the body. If the heading barely moved, CONFIRM with a deliberate
            // in-place spin test before escaping, so a gentle arc that nets ~0
            // rotation can't trigger a false escape.
            if (h0 >= 0 && !borderHandledInSeg) {
                let h1 = stableHeadingBlocking()
                if (h1 >= 0 && angleDiffAbs(h0, h1) < 5) {
                    CutebotPro.pwmCruiseControl(45, -45)        // spin-in-place test
                    basic.pause(250)
                    CutebotPro.stopImmediately(CutebotProMotors.ALL)
                    basic.pause(60)
                    let h2 = stableHeadingBlocking()
                    if (h2 >= 0 && angleDiffAbs(h1, h2) < 12) {
                        unstick()                              // confirmed jam -> break free
                    }
                }
            }
        }
    }
}

// ============================================================
// REPOSITION: drop back to our side, then look down the field
// ============================================================
// When several scans turn up nothing, stop loitering: drive back toward our own
// goal, then turn to face the opponent's goal (where the ball usually is) and
// scan from there. Skipped on low power (too slow to be worth the trip).
function repositionHome() {
    if (!matchRunning || !goalHeadingValid || lowPowerActive) { return }
    strip.showColor(neopixel.colors(NeoPixelColors.Indigo))

    // 1. Face our own side.
    turnToHeading(ownGoalHeading, 15)
    if (!matchRunning) { return }

    // 2. Drive back to our own side. IGNORE the ball on the way: grabbing it here
    //    leaves us on the WRONG side of it (between the ball and our own goal),
    //    which would aim a kick at our OWN net. Get home first, then attack.
    let t0 = input.runningTime()
    let hitEdge = false
    let lastKeep = input.runningTime()
    CutebotPro.pwmCruiseControl(currentPower, currentPower)
    while (input.runningTime() - t0 < REPOSITION_MS) {
        if (!matchRunning) { motorStop(); return }
        if (matchExpired()) { endMatch(); return }
        if (checkBorder() > 0) { motorStop(); hitEdge = true; break }   // reached our back edge
        if (input.runningTime() - lastKeep > 200) {
            lastKeep = input.runningTime()
            CutebotPro.pwmCruiseControl(currentPower, currentPower)     // keep-alive so it gets home
        }
        basic.pause(8)
    }
    motorStop()
    borderSide = 0
    if (!matchRunning) { return }

    // If we stopped RIGHT ON our back edge, pull off the line BEFORE turning. An
    // in-place spin at the edge can walk a wheel off the field, or jam a wheel
    // against the wall (reads as "stuck spinning") and drift us off.
    if (hitEdge) {
        CutebotPro.pwmCruiseControl(-35, -35)
        basic.pause(320)
        motorStop()
        basic.pause(60)
        borderSide = 0
        if (!matchRunning) { return }
    }

    // 3. Turn to look DOWN THE FIELD (toward their goal) and scan from our side.
    turnToHeading(goalHeading, 15)
    if (!matchRunning) { return }
    if (checkBorder() > 0) { handleBorder(); return }   // safety: the turn drifted us to a line
    lookForBall()
}

// ============================================================
// MATCH LOOP  (runs for the whole match)
// ============================================================
function matchLoop() {
    while (matchRunning) {
        handleReset()
        matchTime = (input.runningTime() - matchStartTime) / 1000
        if (matchTime >= 180) { endMatch(); break }

        // Update power from the endurance curve (weakens over the match).
        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
        showEnduranceMeter()
        if (lowPowerActive) { strip.showColor(neopixel.colors(NeoPixelColors.Red)) }

        // Have the ball -> aim & kick it.
        if (ballDetected) {
            lostScans = 0
            tryAimThenCharge()
            if (!matchRunning) { break }
            basic.pause(40)
            if (checkBorder() > 0) { handleBorder() }
            continue
        }

        // After enough roam segments, do a stationary scan. If that finds nothing
        // repeatedly, drop back home and look down the field.
        let scanLimit = lowPowerActive ? 2 : Math.max(3, 3 + Math.round(TURNING / 2))
        if (roamCounter >= scanLimit) {
            roamCounter = 0
            lookForBall()
            if (!ballDetected && matchRunning) {
                lostScans += 1
                if (lostScans >= REPOSITION_AFTER_SCANS && !lowPowerActive) {
                    lostScans = 0
                    repositionHome()
                }
            }
        }

        // Scan may have found the ball -> aim & kick.
        if (ballDetected) {
            lostScans = 0
            tryAimThenCharge()
            if (!matchRunning) { break }
            basic.pause(40)
            if (checkBorder() > 0) { handleBorder() }
            continue
        }

        // Otherwise keep searching with another roam arc.
        roamStep()
    }
}

// ============================================================
// SHOT PREPARATION  (fail-safe own-goal guard)
// ============================================================
// Lock onto the ball, nudge out of the own-goal cone if needed, confirm. Returns
// true when the ball is locked in front and safe to kick.
function prepareShot(): boolean {
    if (!lockOntoBall()) { return false }       // 1. center on the ball
    aimAwayFromOwnGoal()                         // 2. minimal turn out of the own-goal cone
    if (confirmBall(true)) { return true }       // 3. confirm; if the turn nudged it off,
    return lockOntoBall()                        //    re-acquire once
}

// ============================================================
// BALL CONFIRMATION
// ============================================================

// Confirm a stable object within `maxCm`. Takes 4 sonar reads and only confirms
// when at least 3 land in range AND they cluster within CONFIRM_SPREAD_CM. A real
// ball gives a tight cluster; field edges / people / the far wall give scatter.
function confirmWithin(maxCm: number, alreadyStopped: boolean): boolean {
    if (!alreadyStopped) {
        CutebotPro.stopImmediately(CutebotProMotors.ALL)
        basic.pause(50)
    } else {
        basic.pause(30)
    }
    let hits = 0
    let lo = 999
    let hi = 0
    for (let i = 0; i < 4; i++) {
        if (i > 0) { basic.pause(30) }
        let dist = CutebotPro.ultrasonic(SonarUnit.Centimeters)
        if (dist > 3 && dist < maxCm) {
            hits += 1
            if (dist < lo) { lo = dist }
            if (dist > hi) { hi = dist }
        }
    }
    if (hits >= 3 && (hi - lo) <= CONFIRM_SPREAD_CM) { flashHeadlights(); return true }
    return false
}

// Confirm a ball at charge range (close).
function confirmBall(alreadyStopped: boolean = false): boolean {
    return confirmWithin(BALL_DETECT_CM, alreadyStopped)
}

// Confirm a ball at scan range (farther).
function confirmBallScan(): boolean {
    return confirmWithin(BALL_SCAN_CM, false)
}

// ============================================================
// ALIGNMENT GATE
// ============================================================
// ballHeading meaning:  -1 = found on a left sweep | 0 = centered |
//                        1 = found on a right sweep | 99 = found while driving
// Rotates toward the ball if it isn't centered, then hands off to prepareShot.
function alignBeforeCharge(): boolean {
    if (!matchRunning) { return false }
    if (ballHeading == 99) { ballHeading = 0 }
    if (ballHeading == 0) { return prepareShot() }

    let alignPower = Math.max(22, Math.min(45, 18 + TURNING * 3))
    let alignMs = Math.max(60, 220 - TURNING * 16)
    let maxAttempts = Math.max(2, Math.round(TURNING / 2))   // at least 2 tries to center

    for (let attempt = 0; attempt < maxAttempts; attempt++) {
        if (!matchRunning) { return false }
        if (matchExpired()) { endMatch(); return false }
        if (checkBorder() > 0) { handleBorder(); return false }

        if (ballHeading < 0) {
            CutebotPro.pwmCruiseControl(0 - alignPower, alignPower)
        } else {
            CutebotPro.pwmCruiseControl(alignPower, 0 - alignPower)
        }
        basic.pause(alignMs)
        CutebotPro.stopImmediately(CutebotProMotors.ALL)
        basic.pause(40)

        if (confirmBall(true)) { ballHeading = 0; return prepareShot() }
    }
    ballDetected = false
    return false
}

// Aim, then kick if aligned; otherwise drop the ball and go back to searching.
function tryAimThenCharge() {
    if (alignBeforeCharge()) {
        chargeAndFollowThrough()
    } else {
        ballDetected = false
        ballHeading = 0
    }
}

// ============================================================
// STUCK / JAM RECOVERY
// ============================================================
// Break free from a jam: a strong RANDOMIZED reverse + over-90 turn. The
// randomness matters when two identical bots lock together - it breaks symmetry
// so they don't mirror the same escape and re-lock instantly.
function unstick() {
    if (!matchRunning) { return }
    strip.showColor(neopixel.colors(NeoPixelColors.Orange))

    let backMs = 360 + Math.randomRange(0, 240)
    CutebotPro.pwmCruiseControl(-70, -70)
    basic.pause(backMs)
    CutebotPro.stopImmediately(CutebotProMotors.ALL)
    basic.pause(80)
    if (checkBorder() > 0) { handleBorder(); return }   // reversed onto a line?

    let escape = 90 + Math.randomRange(25, 90)
    turnAwayFrom(lastTurnWasLeft ? 1 : 2, escape)
    basic.pause(120)
    CutebotPro.stopImmediately(CutebotProMotors.ALL)
    basic.pause(80)
    if (checkBorder() > 0) { handleBorder(); return }

    CutebotPro.pwmCruiseControl(currentPower, currentPower)
    basic.pause(300)
    CutebotPro.stopImmediately(CutebotProMotors.ALL)
}

// ============================================================
// CHARGE / DRIBBLE / KICK
// ============================================================
// Push the ball toward the goal. While IN CONTACT the push gently curves toward
// the goal heading (open play only); while still approaching it drives straight.
// After each push it chases the ball forward to keep dribbling, only stopping to
// rescan when the ball is truly lost. Includes two jam guards.
function chargeAndFollowThrough() {
    if (!matchRunning) { return }
    flashHeadlights()
    strip.showColor(neopixel.colors(NeoPixelColors.Yellow))

    // Charge power (guarded: launch runs before matchLoop ever sets enduranceFactor).
    let factor = enduranceFactor > 0 ? enduranceFactor : 1
    effectiveCharge = Math.max(Math.round(chargePower * factor), 25)
    effectiveCharge = Math.min(effectiveCharge, 100)
    dynamicCharge = Math.max(effectiveCharge, 30)
    actualFollowThrough = Math.min(FOLLOW_THROUGH_MS, 500)

    // Goal-seeking dribble: bias one wheel so the IN-CONTACT push curves toward the
    // goal heading. Skipped during the dead-reckoned launch (straight kick). Uses
    // turnToHeading's convention (ge > 0 => curve right); if you had to swap
    // turnToHeading for your wiring, swap the two branches below too.
    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) }          // goal right -> curve right
            else if (ge < 0 - GOAL_AIM_TOL) { lPush = Math.max(dynamicCharge - bias, 18) } // goal left  -> curve left
        }
    }

    stuckTimer = input.runningTime()            // sonar-based stuck timer (distance frozen)
    lastBallDist = 0
    let chargeStartTime = input.runningTime()   // total-charge jam cap (sonar-independent)

    while (ballDetected) {
        if (!matchRunning) { return }
        if (matchExpired()) { endMatch(); return }
        if (checkBorder() > 0) { handleBorder(); ballDetected = false; return }

        // --- Stuck timer: reset whenever the ball distance actually changes ---
        let currentDist = CutebotPro.ultrasonic(SonarUnit.Centimeters)
        if (currentDist > 5 && currentDist < BALL_DETECT_CM) {
            if (Math.abs(currentDist - lastBallDist) > 3) {
                stuckTimer = input.runningTime()
                lastBallDist = currentDist
            }
            if (input.runningTime() - stuckTimer > 550) {
                unstick(); ballDetected = false; return
            }
        } else if (currentDist >= BALL_DETECT_CM) {
            stuckTimer = input.runningTime()
            lastBallDist = 0
        }

        // --- Jam cap: a jittering ball can keep resetting the stuck timer while the
        //     wheels free-spin. Cap TOTAL charge time and confirm with a compass
        //     spin-test; escape only if the body truly won't move. ---
        if (input.runningTime() - chargeStartTime > 1800) {
            CutebotPro.stopImmediately(CutebotProMotors.ALL)
            basic.pause(60)
            let hc0 = stableHeadingBlocking()
            CutebotPro.pwmCruiseControl(45, -45)               // spin-in-place test
            basic.pause(250)
            CutebotPro.stopImmediately(CutebotProMotors.ALL)
            basic.pause(60)
            let hc1 = stableHeadingBlocking()
            if (hc0 >= 0 && hc1 >= 0 && angleDiffAbs(hc0, hc1) < 12) {
                unstick(); ballDetected = false; return        // confirmed jam -> break free
            }
            chargeStartTime = input.runningTime()              // moving fine -> keep pushing
        }

        // --- Push: curve only once the ball is in contact; drive straight to close
        //     the final gap (curving early veers off and misses). ---
        if (currentDist > 5 && currentDist < CONTACT_CM) {
            CutebotPro.pwmCruiseControl(lPush, rPush)                  // in contact -> dribble toward goal
        } else {
            CutebotPro.pwmCruiseControl(dynamicCharge, dynamicCharge)  // approaching -> straight at the ball
        }
        let chargeStart = input.runningTime()
        while (input.runningTime() - chargeStart < actualFollowThrough) {
            if (!matchRunning) { return }
            if (resetRequested) { handleReset(); CutebotPro.stopImmediately(CutebotProMotors.ALL); return }
            if (matchExpired()) { endMatch(); return }
            if (checkBorder() > 0) { handleBorder(); ballDetected = false; return }
            basic.pause(5)
        }

        // --- After the push: still in contact? keep driving through. Otherwise the
        //     ball rolled ahead - chase it forward (dribble) instead of turning away. ---
        let f = CutebotPro.ultrasonic(SonarUnit.Centimeters)
        if (f > 5 && f < BALL_DETECT_CM) {
            ballDetected = true            // still in contact -> keep driving through
        } else {
            let ftStart = input.runningTime()
            CutebotPro.pwmCruiseControl(dynamicCharge, dynamicCharge)
            while (input.runningTime() - ftStart < actualFollowThrough) {
                if (!matchRunning) { return }
                if (resetRequested) { handleReset(); CutebotPro.stopImmediately(CutebotProMotors.ALL); return }
                if (matchExpired()) { endMatch(); return }
                if (checkBorder() > 0) { handleBorder(); ballDetected = false; return }
                basic.pause(5)
            }
            // Re-acquire: still ahead within sight -> keep dribbling it on. Only stop
            // and rescan if we've genuinely lost it.
            let g = CutebotPro.ultrasonic(SonarUnit.Centimeters)
            if (g > 5 && g < BALL_SEE_CM) {
                ballDetected = true                    // still ahead -> keep pushing it on
                chargeStartTime = input.runningTime()  // real progress -> reset the jam cap
            } else {
                CutebotPro.stopImmediately(CutebotProMotors.ALL)
                ballDetected = false
                lastTurnWasLeft = !lastTurnWasLeft
                roamCounter = 999          // lost it -> rescan immediately (chase the rebound)
            }
        }
    }
}

// ============================================================
// BALL SCAN
// ============================================================

// Which side of us the goal is on, from the cached heading:
//   -1 = goal to the left | 0 = roughly ahead | 1 = goal to the right.
function goalSide(): number {
    if (!goalHeadingValid) { return 0 }
    if (lastCachedHeading < 0) { return 0 }
    let diff = goalHeading - lastCachedHeading
    if (diff > 180) { diff -= 360 }
    if (diff < -180) { diff += 360 }
    if (Math.abs(diff) < 20) { return 0 }
    return diff > 0 ? 1 : -1
}

// One in-place scan leg, STEP-SCAN style: rotate a small step, STOP, then ping
// while still. (A still sonar beam actually sees the ball; pinging mid-rotation
// sweeps past it during the echo-timeout gap.) Returns:
//   1 = ball found (ballHeading/ballDetected set)
//   2 = aborted (border handled or match stopped) -- caller must return
//   0 = nothing found, leg completed
function sweepLeg(turnLeft: boolean, ms: number, foundHeading: number): number {
    const STEP_MS = 90
    let elapsed = 0
    while (elapsed < ms) {
        if (!matchRunning) { return 2 }
        if (matchExpired()) { endMatch(); return 2 }
        if (checkBorder() > 0) {
            CutebotPro.stopImmediately(CutebotProMotors.ALL)
            basic.pause(40)
            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.
        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 }
        }
    }
    CutebotPro.stopImmediately(CutebotProMotors.ALL)
    return 0
}

// We spotted a ball at scan range and are now facing it: drive straight in so the
// normal charge path (which needs the closer BALL_DETECT_CM) can take the shot.
function closeOnBall() {
    if (!matchRunning) { return }
    driveUntilBall(1100)     // border-aware; stops & confirms once within detect range
    ballHeading = 0          // dead ahead now; matchLoop re-confirms and self-clears if gone
}

// Full search sweep: confirm dead-ahead, then sweep left-out / back / right-out /
// back, biased toward the goal side. On any find, close on the ball and return.
function lookForBall() {
    if (!matchRunning) { return }

    let baseLegMs = Math.max(320, 360 + SPEED * 10)
    if (lastCachedHeading < 0 && goalHeadingValid) { stableHeading() }
    let gs = goalSide()
    // Bias the OUTBOUND excursion toward the goal side; each return leg uses the
    // SAME duration as its outbound, so net rotation per pair is zero.
    let leftLegMs = gs < 0 ? baseLegMs + 25 : gs > 0 ? Math.max(60, baseLegMs - 20) : baseLegMs
    let rightLegMs = gs > 0 ? baseLegMs + 25 : gs < 0 ? Math.max(60, baseLegMs - 20) : baseLegMs

    CutebotPro.stopImmediately(CutebotProMotors.ALL)
    basic.pause(40)
    if (!matchRunning) { return }
    if (confirmBall(true)) { ballHeading = 0; ballDetected = true; return }

    let r = sweepLeg(true, leftLegMs, -1)         // Leg 1: left out
    if (r == 2) { return }
    if (r == 1) { closeOnBall(); return }
    basic.pause(40)

    r = sweepLeg(false, leftLegMs, 0)             // Leg 2: back to center
    if (r == 2) { return }
    if (r == 1) { closeOnBall(); return }
    basic.pause(40)
    if (confirmBall(true)) { ballHeading = 0; ballDetected = true; return }

    r = sweepLeg(false, rightLegMs, 1)            // Leg 3: right out
    if (r == 2) { return }
    if (r == 1) { closeOnBall(); return }
    basic.pause(40)

    r = sweepLeg(true, rightLegMs, 0)             // Leg 4: back to center
    if (r == 2) { return }
    if (r == 1) { closeOnBall(); return }
    basic.pause(60)
}

// ============================================================
// DRIVE AND WATCH  (drive a segment while watching border + ball)
// ============================================================
// Keeps the bot driving at (lSpeed,rSpeed) for `ms`, while: polling the border
// (hard + soft) on a tight cadence, refreshing endurance + the adaptive
// threshold, keeping the motor command alive, and pinging for the ball. Returns
// early (with borderHandledInSeg set) when a border is handled or a ball is found.
function driveAndWatch(ms: number, lSpeed: number, rSpeed: number) {
    if (!matchRunning) { return }

    let watchStart = input.runningTime()
    let lastPingTime = 0
    let lastMeterTime = 0
    let lastBorderPoll = 0
    let lastMotorRefresh = input.runningTime()
    let pingInterval = (SPEED >= 6 || lowPowerActive) ? 25 : 40
    const BORDER_POLL_MS = 6         // tight cadence: border safety outranks I2C smoothness

    while (input.runningTime() - watchStart < ms) {
        if (!matchRunning) { return }
        if (resetRequested) { handleReset(); motorStop(); borderHandledInSeg = true; return }
        if (matchExpired()) { endMatch(); return }

        // ---- Border (throttled; one read serves hard + soft) ----
        if (input.runningTime() - lastBorderPoll >= BORDER_POLL_MS) {
            lastBorderPoll = input.runningTime()
            readTrackbit()

            let hardSide = launchActive ? 0 : borderSideFrom(BORDER_THRESHOLD)
            if (hardSide > 0) {
                // Active brake + short reverse: kill momentum and pull the sensors
                // back off the line so we can't coast off the field.
                CutebotPro.pwmCruiseControl(-60, -60)
                basic.pause(70)
                motorStop()
                borderHandledInSeg = true
                handleBorder()
                return
            }

            // Soft border (pre-line): evade early so we never creep onto the tape.
            let softThresh = Math.round(BORDER_THRESHOLD * (lowPowerActive ? 0.65 : 0.75))
            let softSide = launchActive ? 0 : borderSideFrom(softThresh)
            if (softSide > 0) {
                softEvade(softSide)      // reverses (active brake) + sets borderHandledInSeg
                return
            }
        }

        // ---- Endurance + adaptive border threshold (every 500ms) ----
        if (input.runningTime() - lastMeterTime > 500) {
            lastMeterTime = input.runningTime()
            matchTime = (input.runningTime() - matchStartTime) / 1000
            endurancePercent = 100 - DECAY_RATE * matchTime / 40
            enduranceFactor = Math.max(endurancePercent / 100, 0.2)
            currentPower = Math.max(Math.round(motorPower * enduranceFactor), minWheelSpeed)
            lowPowerActive = currentPower <= lowPowerThreshold
            pingInterval = (SPEED >= 6 || lowPowerActive) ? 25 : 40
            showEnduranceMeter()

            // Track the floor with a smoothed estimate and keep the threshold a
            // fixed margin above it. Moves both ways, clamped [80,220].
            if (borderSide == 0 && input.runningTime() - lastBaselineUpdate > 1500) {
                lastBaselineUpdate = input.runningTime()
                let acc = 0
                for (let k = 0; k < 3; k++) { readTrackbit(); acc += trackbitFloorAvg(); basic.pause(6) }
                let sample = Math.round(acc / 3)
                floorBaseline = Math.round(floorBaseline * 0.7 + sample * 0.3)   // EMA
                BORDER_THRESHOLD = Math.max(80, Math.min(220, floorBaseline + borderMargin))
            }
        }

        // ---- Motor keep-alive (every 200ms) ----
        if (input.runningTime() - lastMotorRefresh > 200) {
            lastMotorRefresh = input.runningTime()
            CutebotPro.pwmCruiseControl(lSpeed, rSpeed)
        }

        // ---- Ball detection while driving ----
        if (Math.max(lSpeed, rSpeed) > 15 && input.runningTime() - lastPingTime > pingInterval) {
            lastPingTime = input.runningTime()
            let dist = CutebotPro.ultrasonic(SonarUnit.Centimeters)
            if (dist > 5 && dist < BALL_DETECT_CM) {
                // Close enough to commit: stop and confirm.
                motorStop()
                if (confirmBall(true)) { ballHeading = 99; ballDetected = true; return }
                // Glanced it mid-arc: switch the segment to STRAIGHT toward it so the
                // keep-alive won't revert to the old curve and orbit the ball.
                let straight = Math.max(lSpeed, rSpeed)
                lSpeed = straight
                rSpeed = straight
                CutebotPro.pwmCruiseControl(straight, straight)
                lastMotorRefresh = input.runningTime()
            } else if (dist > 5 && dist < BALL_SEE_CM) {
                // Saw it at range: stop arcing and drive STRAIGHT at it to close the
                // gap (commit/confirm happens once it's inside BALL_DETECT_CM).
                let straightSee = Math.max(lSpeed, rSpeed)
                lSpeed = straightSee
                rSpeed = straightSee
                CutebotPro.pwmCruiseControl(straightSee, straightSee)
                lastMotorRefresh = input.runningTime()
            }

            // FAILSAFE: the blocking ping can stall border polling long enough to
            // coast onto the line. Re-read the border the instant the ping returns.
            if (!launchActive) {
                readTrackbit()
                if (borderSideFrom(BORDER_THRESHOLD) > 0) {
                    CutebotPro.pwmCruiseControl(-60, -60)
                    basic.pause(70)
                    motorStop()
                    borderHandledInSeg = true
                    handleBorder()
                    return
                }
            }
        }

        basic.pause(5)        // ~200 Hz; plenty, and eases the I2C bus
    }
    // Normal time-out exit: roamStep handles the braking pulse + stop.
}

// Light evade for a SOFT (pre-line) border hit. Brief reverse + a big turn away.
// Gentler/shorter on low power so we can't reverse off the opposite edge.
function softEvade(side: number) {
    motorStop()
    basic.pause(20)
    let backMs = lowPowerActive ? 130 : 200
    let backPwr = lowPowerActive ? -25 : -30
    CutebotPro.pwmCruiseControl(backPwr, backPwr)
    basic.pause(backMs)
    motorStop()
    basic.pause(40)
    turnAwayFrom(side, lowPowerActive ? 130 : 90)
    motorStop()
    borderHandledInSeg = true
    borderSide = 0
}

// ============================================================
// BORDER HANDLING  (full recovery from a hard border hit)
// ============================================================
// Brake, pin our field position from the heading, then back up + turn away +
// nudge forward, retrying up to 3 times until clear. Optionally scans afterward.
function handleBorder(depth: number = 0, scanAfter: boolean = true) {
    if (!matchRunning) { return }
    if (matchExpired()) { endMatch(); return }
    if (depth >= 1) { hardStop(); return }   // recursion guard only

    ballDetected = false
    hardStop()
    updateHomeProximityOnBorder()            // pin field position from heading at the wall
    if (!matchRunning) { return }

    speedExtra = Math.max(SPEED - 4, 0)
    // Low power: gentle, SHORT reverse (no extension) so we never reverse off the
    // opposite edge. Normal power keeps the speed-scaled backup.
    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)
        basic.pause(80)
        if (!matchRunning) { return }

        let angle: number
        if (side == 1 || side == 2) {
            angle = lowPowerActive ? 150 : 115
        } else {
            angle = side == 4 ? 150 + Math.randomRange(0, 20) : 130
            if (lowPowerActive) { angle = Math.min(angle + 30, 180) }
        }
        turnAwayFrom(side, angle)
        if (!matchRunning) { return }

        CutebotPro.stopImmediately(CutebotProMotors.ALL)
        basic.pause(40)
        CutebotPro.pwmCruiseControl(currentPower, currentPower)
        basic.pause(300)
        CutebotPro.stopImmediately(CutebotProMotors.ALL)
        basic.pause(40)

        if (checkBorder() == 0) { break }
    }
    if (!matchRunning) { return }

    sameDirectionCount = 0
    if (scanAfter) { lookForBall() }
}

// ============================================================
// STATS & DISPLAY
// ============================================================

// Derive all runtime values from the four student stats. (See the nonnegotiable
// formulas at the top of the file.)
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(WHEEL_FLOOR, Math.round(motorPower * 0.25))
    lowPowerThreshold = minWheelSpeed
}

// Quick white blink of the front headlights (used to flag a confirmed ball).
function flashHeadlights() {
    CutebotPro.singleHeadlights(CutebotProRGBLight.RGBA, 255, 255, 255)
    basic.pause(30)
    CutebotPro.singleHeadlights(CutebotProRGBLight.RGBA, 0, 0, 0)
}

// Optional: scroll the build archetype so students connect their stat spend to an
// identity. A unique highest stat names the build; a tie => BALANCED.
function showBuildArchetype() {
    let top = Math.max(SPEED, Math.max(POWER, Math.max(TURNING, ENDURANCE)))
    let leaders = 0
    if (SPEED == top) { leaders += 1 }
    if (POWER == top) { leaders += 1 }
    if (TURNING == top) { leaders += 1 }
    if (ENDURANCE == top) { leaders += 1 }
    let name = "BALANCED"
    if (leaders == 1) {
        if (SPEED == top) { name = "SPEEDSTER" }
        else if (POWER == top) { name = "SNIPER" }
        else if (ENDURANCE == top) { name = "TANK" }
        else { name = "NINJA" }
    }
    basic.showString(name)
}

// Quick bar chart of the four stats on cols 0-3: SPEED, TURNING, ENDURANCE, POWER.
function showStatBars() {
    basic.clearScreen()
    let vals = [SPEED, TURNING, ENDURANCE, POWER]
    for (let c = 0; c < 4; c++) {
        let h = Math.max(1, Math.min(5, Math.round(vals[c] / 2)))
        for (let y = 5 - h; y <= 4; y++) { led.plot(c, y) }
    }
}

// Live endurance meter on column 2 (taller = more power left).
function showEnduranceMeter() {
    bars_count = Math.round(enduranceFactor * 5)
    bars_count = Math.max(1, Math.min(5, bars_count))
    for (let y = 0; y <= 4; y++) { led.unplot(2, y) }
    for (let y = 5 - bars_count; y <= 4; y++) { led.plot(2, y) }
}

// ============================================================
// COMPASS / FIELD-POSITION HELPERS
// ============================================================

// Smallest absolute angle between two headings (0-180).
function angleDiffAbs(a: number, b: number): number {
    let d = Math.abs(a - b)
    if (d > 180) { d = 360 - d }
    return d
}

// Own-goal "danger cone" half-width. Widens toward cautious (WIDE) as our
// position estimate goes stale (no recent wall contact).
function ownGoalCone(): number {
    let elapsed = input.runningTime() - lastHomeUpdate
    let caution = Math.min(1, elapsed / HOME_DECAY_MS)
    let p = homeProximity + (1 - homeProximity) * caution
    return Math.round(OWN_GOAL_CONE_NARROW +
        (OWN_GOAL_CONE_WIDE - OWN_GOAL_CONE_NARROW) * p)
}

// Pin which half we're in from the heading at a wall (called from handleBorder
// while stopped): facing our own goal => home end; facing their goal => far end.
function updateHomeProximityOnBorder() {
    if (!goalHeadingValid) { return }
    let h = stableHeadingBlocking()
    if (h < 0) { return }
    if (angleDiffAbs(h, ownGoalHeading) <= 70) { homeProximity = 1 }
    else if (angleDiffAbs(h, goalHeading) <= 70) { homeProximity = 0 }
    lastHomeUpdate = input.runningTime()
}

// Rotate in place to an absolute compass heading (shortest way). Gives up rather
// than spinning blindly if the compass can't be read.
// NOTE: err > 0 means turn clockwise. If your bot turns the WRONG way for its
// wiring, swap the two pwmCruiseControl lines below.
function turnToHeading(target: number, tolDeg: number = 10) {
    let turnPwr = Math.max(25, Math.min(40, 20 + TURNING * 2))
    for (let i = 0; i < 30; i++) {
        if (!matchRunning) { return }
        let h = stableHeadingBlocking()
        if (h < 0) { motorStop(); return }
        let err = target - h
        if (err > 180) { err -= 360 }
        if (err < -180) { err += 360 }
        if (Math.abs(err) <= tolDeg) { motorStop(); return }
        if (err > 0) { CutebotPro.pwmCruiseControl(turnPwr, 0 - turnPwr) }
        else { CutebotPro.pwmCruiseControl(0 - turnPwr, turnPwr) }
        basic.pause(Math.max(40, Math.min(180, Math.abs(err) * 4)))
        motorStop()
        basic.pause(50)
    }
    motorStop()
}

// Re-center on a ball that isn't dead-ahead: a tiny zero-net L/R/R/L sweep,
// stopping on the first confirmed hit. Returns true if the ball is locked in front.
function lockOntoBall(): boolean {
    if (confirmBall(true)) { return true }
    let p = 24
    let dirs = [-1, 1, 1, -1]          // small L, R, R, L -- net rotation ~0
    for (let i = 0; i < dirs.length; i++) {
        if (!matchRunning) { return false }
        if (checkBorder() > 0) { motorStop(); return false }   // let matchLoop handle the border
        if (dirs[i] < 0) { CutebotPro.pwmCruiseControl(0 - p, p) }
        else { CutebotPro.pwmCruiseControl(p, 0 - p) }
        basic.pause(90)
        motorStop()
        basic.pause(40)
        if (confirmBall(true)) { return true }
    }
    return false
}

// If we're inside the own-goal danger cone, turn out via the SHORTER edge (minimal
// move, so the ball stays nearest dead-ahead). Always returns true; the real
// safety is the cone test that calls this from prepareShot.
function aimAwayFromOwnGoal(): boolean {
    if (!goalHeadingValid) { return true }
    let h = stableHeadingBlocking()
    if (h < 0) { return true }          // stopped + unreadable is rare; don't spin in circles
    let cone = ownGoalCone()
    let d = h - ownGoalHeading
    while (d > 180) { d -= 360 }
    while (d < -180) { d += 360 }
    if (Math.abs(d) >= cone) { return true }
    let edge = d >= 0 ? (cone + 8) : 0 - (cone + 8)
    turnToHeading((ownGoalHeading + edge + 360) % 360, 10)
    return true
}

// ============================================================
// VARIABLES (module state)
// ============================================================

// --- Match / control state ---
let matchRunning = false
let readyToLaunch = false
let resetRequested = false
let launchActive = false
let matchStartTime = 0
let matchTime = 0

// --- Ball state ---
let ballDetected = false
let ballHeading = 0
let stuckTimer = 0
let lastBallDist = 0

// --- Roam / scan state ---
let roamCounter = 0
let lostScans = 0
let sameDirectionCount = 0
let maxSameDirection = 0
let lastTurnWasLeft = false
let borderHandledInSeg = false

// --- Power / endurance (derived from stats each loop) ---
let motorPower = 0
let currentPower = 0
let enduranceFactor = 0
let endurancePercent = 0
let DECAY_RATE = 0
let minWheelSpeed = 0
let lowPowerThreshold = 0
let lowPowerActive = false
let WHEEL_FLOOR = 15   // min wheel speed

// --- Charge / kick scratch ---
let chargePower = 0
let effectiveCharge = 0
let dynamicCharge = 0
let FOLLOW_THROUGH_MS = 0
let actualFollowThrough = 0

// --- Roam timing / turning (derived) ---
let totalArcTime = 0
let thisDuration = 0
let turnRatio = 0

// --- Border handling scratch ---
let borderSide = 0
let backupTime = 0
let backupPower = 0
let speedExtra = 0
let floorBaseline = 0
let lastBaselineUpdate = 0

// --- Compass / heading state ---
let goalHeading = 0
let goalHeadingValid = false
let ownGoalHeading = 0
let lastCachedHeading = -1
let lastHeadingTime = 0

// --- Adaptive own-goal cone ---
let OWN_GOAL_CONE_NARROW = 35      // far end: allow tight upfield shots
let OWN_GOAL_CONE_WIDE = 80        // home end: very cautious
let HOME_DECAY_MS = 8000           // no wall contact this long -> fully cautious
let homeProximity = 1              // 0 = far end, 1 = home end
let lastHomeUpdate = 0
let GOAL_AIM_TOL = 18              // within this many degrees of the goal heading, push straight

// --- Line sensor raw values (filled by readTrackbit) ---
let tb1 = 0
let tb2 = 0
let tb3 = 0
let tb4 = 0

// --- Detection ranges / thresholds (most set in INITIALIZATION below) ---
let BALL_DETECT_CM = 0
let BALL_SCAN_CM = 0
let BALL_SEE_CM = 0
let CONTACT_CM = 0
let CONFIRM_SPREAD_CM = 10         // tightest cluster (cm) accepted as a real ball
let BORDER_THRESHOLD = 0
let borderMargin = 0
let BASE_SEGMENT_MS = 0
let scanPower = 0

// --- Reposition tunables ---
let REPOSITION_AFTER_SCANS = 3     // fruitless scans before dropping back to our side
let REPOSITION_MS = 2400           // how long to drive back toward our own goal

// --- Launch tunables ---
let LAUNCH_OFFSET_TURN_DEG = 35    // Step 1: turn toward center
let LAUNCH_OFFSET_DRIVE_MS = 2520  // Step 2: diagonal onto the goal axis
let LAUNCH_SQUARE_TURN_DEG = 35    // Step 3: re-square to the goal (= Step 1)
let LAUNCH_APPROACH_MS = 500       // Step 4: max forward time to reach the ball

// --- Student stats ---
let SPEED = 0
let ENDURANCE = 0
let TURNING = 0
let POWER = 0

// --- Display ---
let strip: neopixel.Strip = null
let bars_count = 0

// ============================================================
// HARDWARE CONSTANTS & INITIALIZATION
// ============================================================
strip = neopixel.create(DigitalPin.P15, 8, NeoPixelMode.RGB)
strip.setBrightness(255)

CutebotPro.stopImmediately(CutebotProMotors.ALL)
CutebotPro.pwmCruiseControl(0, 0)
basic.pause(100)

basic.showString("CAL")
input.compassHeading()       // wake/seed the compass
basic.clearScreen()

/* ============================================================================
 *  ★ STUDENT ZONE A — STAT ALLOCATION ★
 * ============================================================================
 *  Give your bot its personality. Fill in the four stats below.
 *
 *  RULES:
 *   • Each stat is a whole number, at least 1.
 *   • POWER goes up to 10  (Power² caps the kick at 100 there — more is wasted).
 *   • SPEED, ENDURANCE, and TURNING go up to 17.
 *   • The four values MUST add up to exactly 20 (your point budget).
 *
 *  WHAT EACH ONE DOES:
 *   SPEED      how fast it drives around the field
 *   TURNING    how sharply it arcs while searching / how fast it aims
 *   ENDURANCE  how well it keeps its power late in the match
 *   POWER      how hard it kicks the ball
 *
 *  TRADE-OFFS: every point you add to one stat is a point taken from another --
 *  you cannot be great at everything. Pick a strategy:
 *    SPEEDSTER = high SPEED · SNIPER = high POWER ·
 *    TANK = high ENDURANCE · NINJA = high TURNING
 *
 *  EXAMPLE (a balanced build):  SPEED 5 + TURNING 5 + ENDURANCE 5 + POWER 5 = 20
 * ============================================================================
 */
SPEED     = 6    // SOLUTION build (SNIPER):  6 + 2 + 4 + 8 = 20
TURNING   = 2
ENDURANCE = 4
POWER     = 8

// ── Tunable distances / thresholds ─────────────────────────
BASE_SEGMENT_MS = 300
BORDER_THRESHOLD = 130     // overwritten by calibrateThreshold
borderMargin = 40          // overwritten by calibrateThreshold
scanPower = 25             // in-place scan/turn speed
BALL_DETECT_CM = 40        // commit-to-charge range
BALL_SEE_CM = 45           // home-in range while driving; keep < half field width
CONTACT_CM = 15            // only dribble-curve once the ball is THIS close (in contact)
BALL_SCAN_CM = 50          // scan-spot range; shorter = fewer false hits on walls/people
// ───────────────────────────────────────────────────────────

basic.showIcon(IconNames.SmallSquare)
Velocity Arena · NYC FIRST. Also see: Skills Arena reference · How to Run Skills Arena.