Skip to content

Examples

Each example on this page is the whole of src/main.py. Copy one, change the port constants at the top to match your robot, and run it.

They all have the same four parts:

from marlin import Robot, Thruster    # 1. what you need

THRUSTER = "M1"                       # 2. which ports your hardware is on
robot = Robot()
robot.configure(
    sensors={},
    effectors={THRUSTER: Thruster},
)

def driver(robot):                    # 3. what the robot does, each phase
    while robot.running:
        ...

robot.run(driver=driver)              # 4. hand the file to MARLIN

Every example below changes only part 3. Parts 1, 2, and 4 differ just enough to declare the hardware that example uses.

If you have not built a whole robot program yet, read Complete robot program first. It grows one file from a single thruster to both match phases, one step at a time. Come back here for individual patterns to drop into it.

Example What it gets you
Single thruster driver control The robot moves when you push the stick
Tank drive Driving and turning with two sticks
Dead zone The robot stops drifting when you let go
Speed limit for fine control A slow mode for lining up precisely
Servo on button press An arm that toggles once per press
Trigger and D-pad control Using the triggers and the D-pad
Timed autonomous movement The robot does something on its own
Autonomous step sequence A multi-step auton routine
Gyro turn estimate Turning by roughly a set angle
Beacon seeking Homing in on the Beacon during auton
Using two examples at once Combining any of the above

Single thruster driver control

from marlin import Robot, Thruster

THRUSTER = "M1"

robot = Robot()
robot.configure(sensors={}, effectors={THRUSTER: Thruster})


def driver(robot):
    while robot.running:
        robot.thruster(THRUSTER).set_duty(robot.controller.joystick("ONE").value)


robot.run(driver=driver)

Joystick ONE is the left stick's up and down axis. .value gives -100 to 100, which is exactly the range set_duty() wants, so no conversion is needed.

You should see: pushing the left stick forward spins the thruster, pulling it back reverses it, and centring the stick stops it.

Tank drive

from marlin import Robot, Thruster

LEFT  = "M8"
RIGHT = "M1"

robot = Robot()
robot.configure(
    sensors={},
    effectors={LEFT: Thruster, RIGHT: Thruster},
)


def driver(robot):
    while robot.running:
        left = robot.controller.joystick("ONE").value       # left stick, up/down
        right = robot.controller.joystick("THREE").value    # right stick, up/down

        robot.thruster(LEFT).set_duty(left)
        robot.thruster(RIGHT).set_duty(right)


robot.run(driver=driver)

You should see: both sticks forward drives the robot straight, both back reverses it, and one forward while the other is pulled back spins it in place.

Each stick owns one side of the boat and nothing is mixed. A stick reads -100 to 100 and set_duty() takes -100 to 100, so each value goes straight through with no arithmetic of your own and nothing to clamp.

Turning costs you both sticks, which is the trade-off. Some robots use arcade drive instead, where one stick sets speed, another sets turn, and the two are combined before they reach the thrusters. Tank drive keeps each side independent, which is easier to reason about when something misbehaves.

If you later compute a duty rather than passing a stick straight through, see Range clamping for what happens past the ends of the range.

Dead zone

A stick that does not sit perfectly centred reports a small value when nobody is touching it, and the robot creeps. Treat small values as zero:

from marlin import Robot, Thruster

THRUSTER = "M1"

robot = Robot()
robot.configure(sensors={}, effectors={THRUSTER: Thruster})


def driver(robot):
    while robot.running:
        power = robot.controller.joystick("ONE").value

        # abs() ignores the sign, so -3 and 3 both count as small.
        if abs(power) < 5:
            power = 0

        robot.thruster(THRUSTER).set_duty(power)


robot.run(driver=driver)

You should see: the robot holds still with the stick released, and starts moving as soon as you push past a light touch.

Raise the 5 if the robot still creeps. Lower it if the stick feels dead near the centre.

Speed limit for fine control

Multiplying the joystick value scales the whole range at once. Hold a trigger for precise, slow movement:

from marlin import Robot, Thruster

THRUSTER = "M1"

robot = Robot()
robot.configure(sensors={}, effectors={THRUSTER: Thruster})


def driver(robot):
    while robot.running:
        power = robot.controller.joystick("ONE").value

        if robot.controller.button("LEFT_TRIGGER").is_down:
            power = power * 0.4      # 40% of normal speed while held

        robot.thruster(THRUSTER).set_duty(power)


robot.run(driver=driver)

You should see: the robot drives normally. While you hold the left trigger it moves noticeably slower, even at full stick.

Scaling by 0.4 maps the stick's full -100..100 travel onto -40..40. The whole range of the stick still does something. It just moves the robot more slowly. Multiplying is the right tool here rather than clamping. Clamping would throw away everything past 40 and leave most of the stick's travel dead.

Servo on button press

from marlin import Robot, Servo

ARM = "M2"

robot = Robot()
robot.configure(sensors={}, effectors={ARM: Servo})


def driver(robot):
    previous_a = False
    is_open = False

    while robot.running:
        current_a = robot.controller.button("A").is_down

        # True only on the loop where the button went from up to down.
        if current_a and not previous_a:
            if is_open:
                is_open = False
                robot.servo(ARM).angle(0)
            else:
                is_open = True
                robot.servo(ARM).angle(90)

        previous_a = current_a


robot.run(driver=driver)

You should see: each press of A swings the servo to the other position and it stays there. Holding A down does nothing more.

is_down is a level, not an event. Without the previous_a comparison, a single half-second press would toggle the servo dozens of times.

is_open remembers which position the servo is in, because the servo itself cannot tell you.

Trigger and D-pad control

The triggers and the D-pad are buttons, read exactly like A and B:

from marlin import Robot, Servo, Thruster

LEFT  = "M8"
RIGHT = "M1"
ARM   = "M2"

robot = Robot()
robot.configure(
    sensors={},
    effectors={LEFT: Thruster, RIGHT: Thruster, ARM: Servo},
)


def driver(robot):
    while robot.running:
        # D-pad up and down nudge the robot at a fixed, slow speed.
        forward = 0
        if robot.controller.button("UP").is_down:
            forward = 25
        if robot.controller.button("DOWN").is_down:
            forward = -25

        robot.thruster(LEFT).set_duty(forward)
        robot.thruster(RIGHT).set_duty(forward)

        # Triggers hold the arm at one of two positions.
        if robot.controller.button("RIGHT_TRIGGER").is_down:
            robot.servo(ARM).angle(120)
        else:
            robot.servo(ARM).angle(0)


robot.run(driver=driver)

You should see: the D-pad nudges the robot slowly forward and back, and the arm sits at 120° while you hold the right trigger and snaps back to 0° when you let go.

Timed autonomous movement

import time

from marlin import Robot, Thruster

LEFT  = "M8"
RIGHT = "M1"

robot = Robot()
robot.configure(sensors={}, effectors={LEFT: Thruster, RIGHT: Thruster})


def autonomous(robot):
    start = time.monotonic()

    while robot.running:
        elapsed = time.monotonic() - start

        if elapsed < 2.0:
            robot.thruster(LEFT).set_duty(30)
            robot.thruster(RIGHT).set_duty(30)
        else:
            robot.thruster(LEFT).set_duty(0)
            robot.thruster(RIGHT).set_duty(0)


robot.run(autonomous=autonomous)

You should see: on Start Auton, the robot drives straight for two seconds, then stops and holds still until the period ends.

time.monotonic() returns seconds as a decimal number. Take one reading before the loop, then subtract it inside the loop to get elapsed time.

The Autonomous Period is 20 seconds

A routine whose deadlines add up past 20 seconds never finishes. And in a Qualification Match your Alliance partner's robot is in the Pool running its own routine at the same time, so do not assume you have the Pool to yourself. See Alliance.

Do not use time.sleep() to time a movement, such as time.sleep(2) to drive for two seconds. While your code is sleeping it cannot read a sensor, react to the controller, or change what the robot is doing. Measure elapsed time with time.monotonic() instead, as above. Your loop then keeps running throughout.

Your loop needs no wait of its own at all: reading robot.running paces it to fifty times a second, matching the rate MARLIN talks to the robot. See running.

Autonomous step sequence

Several timed steps in a row follow the same shape: compare the elapsed time against a growing list of deadlines.

import time

from marlin import Robot, Thruster

LEFT  = "M8"
RIGHT = "M1"

robot = Robot()
robot.configure(sensors={}, effectors={LEFT: Thruster, RIGHT: Thruster})


def drive(robot, left, right):
    robot.thruster(LEFT).set_duty(left)
    robot.thruster(RIGHT).set_duty(right)


def autonomous(robot):
    start = time.monotonic()

    while robot.running:
        elapsed = time.monotonic() - start

        if elapsed < 2.0:
            drive(robot, 30, 30)      # forward
        elif elapsed < 3.0:
            drive(robot, 30, -30)     # turn
        elif elapsed < 5.0:
            drive(robot, 30, 30)      # forward again
        else:
            drive(robot, 0, 0)        # stop and hold


robot.run(autonomous=autonomous)

You should see: forward for two seconds, a one-second turn, forward again for two seconds, then stop. That is five seconds of movement inside the 20-second period.

The deadlines are cumulative: the turn runs from 2.0 s to 3.0 s, so it lasts one second. The drive() helper keeps each step to a single readable line.

Gyro turn estimate

Advanced

This example uses maths on a sensor reading, and the result is only an estimate. Get the earlier examples working first. A timed turn, like the one in Autonomous step sequence, is simpler and often good enough.

MARLIN's IMU has no heading, bearing, or compass field. What it does report is gyro_z, a rotation rate in degrees per second. To estimate how far the robot has turned, integrate gyro_z over time: multiply each loop's rate by the time since the last loop (dt) and add it to a running total.

The units line up on purpose: gyro_z is in degrees per second, and time.monotonic() returns seconds, so gyro_z * dt gives you degrees. If you swap in a timer that reports milliseconds (like time.time() * 1000) without converting, the math still runs but the result is wrong.

Note that imu() is a method here, not a property - it needs the trailing (). If you forget it, you'll get a confusing AttributeError instead of an ImuReading; see Troubleshooting if that happens to you.

import time

from marlin import IMU, Robot, Thruster

LEFT  = "M8"
RIGHT = "M1"
IMU_PORT = "S1"

robot = Robot()
robot.configure(
    sensors={IMU_PORT: IMU},
    effectors={LEFT: Thruster, RIGHT: Thruster},
)


def autonomous(robot):
    turned_deg = 0.0
    last_time = time.monotonic()

    while robot.running:
        now = time.monotonic()
        dt = now - last_time  # seconds, since time.monotonic() counts in seconds
        last_time = now

        # imu() is a method, not a property, so the () is required.
        reading = robot.imu(IMU_PORT).read()

        if not reading.ok:
            robot.thruster(LEFT).set_duty(0)
            robot.thruster(RIGHT).set_duty(0)
            continue

        turned_deg += reading.gyro_z * dt

        if turned_deg >= 90:
            robot.thruster(LEFT).set_duty(0)
            robot.thruster(RIGHT).set_duty(0)
        else:
            robot.thruster(LEFT).set_duty(25)
            robot.thruster(RIGHT).set_duty(-25)


robot.run(autonomous=autonomous)

You should see: the robot spins in place until it has turned roughly a quarter circle, then stops. Expect it to overshoot or undershoot, because this is only an estimate.

This estimate drifts: small errors in each sample accumulate every loop, so turned_deg slowly wanders away from the robot's true turn. It is not a compass reading and it does not reset itself. Use it only to estimate a single short turn, not to track heading over a whole match.

Beacon seeking

The Beacon is a Link Lockdown Scoring Element: a yellow float that broadcasts a radio signal. The game manual describes exactly this approach. Navigate to the Beacon by moving in the direction where the signal gets stronger. See Game Manual.

Do not assume the Beacon holds still. It is untethered by default, and the Event Partner may or may not weight it down, so it can drift during your auton. A routine that drives to a memorised position will miss it; seek the signal every loop.

RadioBeacon reports signal strength (rssi) in dBm. It is a negative number: -60 is a stronger signal than -90. It is not a distance, so use it only to tell "stronger" from "weaker".

from marlin import RadioBeacon, Robot, Thruster

LEFT  = "M8"
RIGHT = "M1"
BEACON = "S6"

robot = Robot()
robot.configure(
    sensors={BEACON: RadioBeacon},
    effectors={LEFT: Thruster, RIGHT: Thruster},
)


def autonomous(robot):
    while robot.running:
        beacon = robot.radiobeacon(BEACON).read()

        if not beacon.ok:
            # No fresh reading: hold still rather than spin blind.
            robot.thruster(LEFT).set_duty(0)
            robot.thruster(RIGHT).set_duty(0)
        elif beacon.rssi >= -80:
            # Strong enough: stop turning.
            robot.thruster(LEFT).set_duty(0)
            robot.thruster(RIGHT).set_duty(0)
        else:
            # Weak: spin in place and keep looking.
            robot.thruster(LEFT).set_duty(30)
            robot.thruster(RIGHT).set_duty(-30)


robot.run(autonomous=autonomous)

You should see: the robot spinning in place while the signal is weak, and stopping once it is pointed somewhere the signal reads stronger than -80 dBm.

Check beacon.ok first. A missing reading reports rssi as 0.0, which would otherwise look like the strongest possible signal.

Using two examples at once

Each example replaces the body of one callback. To combine two, put both bodies in the same loop and declare every port they use in one configure() call.

Here are tank drive, the dead zone, and the servo toggle in one file:

from marlin import Robot, Servo, Thruster

LEFT  = "M8"
RIGHT = "M1"
ARM   = "M2"

robot = Robot()
robot.configure(
    sensors={},
    effectors={LEFT: Thruster, RIGHT: Thruster, ARM: Servo},   # every port, once
)


def driver(robot):
    previous_a = False
    is_open = False

    while robot.running:
        # --- from Tank drive ---
        left = robot.controller.joystick("ONE").value
        right = robot.controller.joystick("THREE").value

        # --- from Dead zone ---
        if abs(left) < 5:
            left = 0
        if abs(right) < 5:
            right = 0

        robot.thruster(LEFT).set_duty(left)
        robot.thruster(RIGHT).set_duty(right)

        # --- from Servo on button press ---
        current_a = robot.controller.button("A").is_down
        if current_a and not previous_a:
            is_open = not is_open
            robot.servo(ARM).angle(90 if is_open else 0)
        previous_a = current_a


robot.run(driver=driver)

Three things to watch when you merge:

  • Variables that live across loops, such as previous_a and is_open, go above the while, not inside it. Put them inside and they reset every pass, which stops the press detection from working.
  • Declare every port once. One configure() call lists all the hardware the merged file uses.
  • Do not command the same effector twice in one pass. If two examples both call set_duty() on LEFT, the last call wins and the first one silently does nothing. Combine the values into one call instead, the way the dead zone adjusts left before it reaches set_duty().

Combining an autonomous example with a driver example is easier: they are separate functions, so keep both and pass both to run():

robot.run(autonomous=autonomous, driver=driver)

Complete robot program builds all of this into one file, one step at a time, and adds an IMU and an initialize check.

For the rules behind these patterns, read Effector commands and safety and State and sensor data.