Coding your RoboSoccer team

Student handbook · Kit 0.3.0 · Python 3.11 or newer

For your first lesson, use the one-page quickstart. Follow the short lessons and experiments over several sessions. This handbook is the reference to return to when you need a detail.

You write one Python file. The match runner calls your code for each robot, giving it sensor readings and asking what to do next. Start with a reliable ball chaser, then improve its positioning, perception and teamwork. You do not need machine learning or advanced mathematics to enter the league.

First match in five minutes

  1. Open your teacher's arena address and choose Get the local kit.
  2. Unzip the download. Open a terminal in the unzipped folder, where pyproject.toml is located.
  3. Run the commands below. Edit the new my_bot.py in your preferred code editor.
sh
python -m pip install .
robosoccer new my_bot.py
robosoccer check my_bot.py
robosoccer run my_bot.py --vs chaser --seed 3 --watch

The last command plays a full match and opens an offline replay. It may take a little time to finish. The browser shows the recorded match; Python runs the bot. For a quicker experiment, add --half-seconds 5 for two five-second halves.

If python is unavailable, try py on Windows or python3 on macOS/Linux. If robosoccer is not found after installation, use python -m robosoccer instead of robosoccer in every command. Run python --version to check Python. Use the same Python installation for installing and running the kit. The download installs locally; you do not need a PyPI account.

If installation reports an externally managed environment or a permission error, create a virtual environment with python -m venv .venv. On Windows, use .venv\Scripts\python -m pip install . and .venv\Scripts\python -m robosoccer .... On macOS/Linux, use .venv/bin/python in the same way. The ... means the command arguments, such as new my_bot.py; do not type the dots literally.

To work entirely in the website, create an account, open My bot, click Load starter, edit the code, and choose Check my bot. The local kit is useful for repeated experiments without uploading each change.

The match loop

Do not write your own continuous game loop, wait for the next tick, call sleep(), or request keyboard input. The engine owns the loop. A tick is simulated time; wall-clock time measures how long your code actually spends computing.

Your two functions

python
def setup(info: dict) -> dict | None:
    return {"speed": info["robot"]["cruise_speed"]}

def act(robot_id: int, sensors: dict, memory: dict) -> dict:
    return {"move": (memory["speed"], 0.0), "kick": 0.0}

This tiny example drives straight; it is only an API demonstration. act is required. setup is optional and runs once per team when the match starts. If it is omitted or returns None, each robot starts with an empty memory dict. Return an initial dict from setup; return an action dict from act.

info contains these public fields:

KeyMeaning
engine_version, dtKit version and seconds per tick
teamYour team index, 0 or 1, for interpreting sensors["score"]
side, attack_directionInitial left/right side and initial direction
number_of_robotsRobot count; normally 3
field, landmarksDimensions and known landmark map, in metres
robot, kickMovement, stamina and kicking limits
sensors, communication, rulesPublic settings

Copy settings you need into the dict returned by setup. info is not passed to act. In particular, save info["team"] if you want to know which score is yours. During play, read sensors["attack_direction"] because ends change at halftime.

Actions: move, turn, kick and broadcast

python
return {
    "move": (0.8, 1.2),
    "kick": 0.0,
    "broadcast": [1, sensors["tick"], 0.7],
}
ActionUnits and behavior
move[0]Target forward speed in metres/second. Negative drives backward.
move[1]Turn rate in radians/second. Positive turns left; negative turns right.
kickPower from 0 to 1. Zero means do not kick.
broadcastOptional list of 1–16 finite numbers for teammates.

move may be a tuple or list. Missing movement or kick defaults to zero; {} is a valid no-op. Extra-large valid movement values are clipped to physical limits. Return finite numbers, not strings, booleans, None, NaN or infinity. Invalid radio packets are dropped; an invalid action or memory update can count as an execution violation. Use lists rather than sets for packets.

Default physical limits:

SettingDefault
Maximum forward/backward speed1.8 m/s, reduced by stamina
Cruise speed1.0 m/s
Maximum acceleration3.0 m/s²
Maximum turn rate3.5 rad/s
Exhausted speed cap0.65 m/s
Robot / ball radius0.18 m / 0.065 m
Kick range0.12 m surface gap, in front of the robot
Kick facing tolerance±0.65 radians, about ±37°
Kick cooldown0.65 seconds
Full-power kick speedAbout 7 m/s, with match variation

A move command is a target, not a teleport. Acceleration, collisions and stamina affect actual motion. Zero forward speed requests braking; it does not instantly stop existing velocity. Sprinting above cruise speed drains energy; moving more slowly allows recovery. Check sensors["stamina"] before committing to a chase.

The ball is never automatically attached to a robot. Push it through contact, or kick it when close enough and correctly facing it. The camera's ball distance is centre-to-centre, so 0.12 is not the camera-distance threshold. At default sizes, the engine's maximum kick centre distance is 0.365 m. The noisy ball_in_kicker flag is a hint; the engine checks real geometry and cooldown. Kicks travel along the robot's heading, with a little random angle error. A large power value cannot compensate for facing the wrong way.

Angles and reference frames

All code angles are radians. math.pi is 180°, and math.pi / 2 is 90°. Camera and proximity bearings are relative to the robot's current forward direction:

Robot bearings: straight ahead is zero, positive 0.5 radians is left and negative 0.5 radians is right.
BearingObject location
0.0Straight ahead
+0.5About 29° to the left
-0.5About 29° to the right

To face a visible object, a useful starting rule is turn = 4 * bearing. Slow down while making a large turn. Never pass degrees such as 90 as a turn rate.

The IMU heading is a noisy direction in the field's frame. The field centre is (0, 0), positive x points toward the right goal, and positive y points up the pitch. Heading 0 faces right; math.pi / 2 faces up; ±math.pi faces left. The default field runs from x = -4.5 to +4.5 and y = -3 to +3 metres. attack_direction is +1 for the right goal and -1 for the left goal. These coordinates describe the map; your true robot position is not supplied.

The same robot has a positive field-frame IMU heading and a negative robot-relative camera bearing to a ball on its right.

In this example the robot points northeast: IMU heading is about +0.79 radians. The ball is to the right of its nose: camera bearing is about -0.79 radians. The readings describe different angles; neither tells you your true x/y position.

Use angle wrapping when subtracting headings:

python
def wrap_angle(angle):
    return (angle + math.pi) % (2 * math.pi) - math.pi

For example, the difference between +179° and -179° should be a 2° turn, not 358°.

Sensors: what your robot actually receives

FieldMeaning
tick, dtGlobal tick number and seconds per tick
half, phaseHalf 1 or 2; phase is kickoff, playing, or finished
score[team_0_goals, team_1_goals], not automatically yours first
attack_directionCurrent attacking direction; flips at halftime
cameraList of nearby visible object detections
ball_in_kickerNoisy boolean indicating ball range and facing
imuheading in radians and angular_velocity in rad/s
odometryBody-frame forward and lateral velocity estimates in m/s
proximityClose wall/robot readings, including contact hints
staminaEnergy from 0 to 1
kick_cooldownSeconds until the kicker is ready
messagesTeammate packets delivered on this tick

An example camera reading looks like this; the numbers change each tick:

python
{"kind": "ball", "distance": 1.2, "bearing": -0.25}
{"kind": "teammate", "id": 2, "distance": 0.8, "bearing": 0.4}
{"kind": "goal", "id": "right", "distance": 2.1, "bearing": 0.1}

Camera kinds are ball, teammate, opponent, goal, landmark, and line. Ball readings have no ID; use .get("id") when checking IDs. Robot IDs belong to their own team. Goal IDs are left and right. Landmark IDs are center, corner_sw, corner_nw, corner_se, corner_ne; their fixed coordinates are in info["landmarks"]. Line IDs are left, right, bottom, top, halfway. A line reading measures its closest point, not its midpoint.

The camera sees a 110° cone up to 2.5 m away. Other robots can obscure objects. An empty camera list is normal. Roughly 6% of otherwise visible objects are missed per tick. Rare false ball/opponent detections can appear. Distance and bearing have Gaussian noise which increases with distance. Do not assume camera order is a ranking or that camera[0] is the ball.

Camera distances are centre-to-centre. Proximity distances are surface gaps. A proximity entry includes kind, distance, bearing, bump, and sometimes id; walls have a side ID, nearby robots do not. The default proximity range is 0.35 m. IMU heading drifts; wheel odometry also has noise. No sensor gives a true global x/y position, true global ball velocity, or the full opponent state.

Filter camera detections safely:

python
balls = [obj for obj in sensors["camera"] if obj["kind"] == "ball"]
ball = min(balls, key=lambda obj: obj["distance"]) if balls else None

Choosing the closest detection is a beginner heuristic; a later tracker can reject impossible jumps and combine several observations. A missing ball does not mean the ball stopped or disappeared from the field.

--perfect-sensors removes noise, missed detections and false positives locally. It still limits camera range, view angle and occlusion. Radio loss/delay and physics randomization remain. The server always uses the configured noisy model.

Memory: save state correctly

Each robot gets its own persistent dictionary. The setup result is copied independently to every robot. Robot 0 cannot read robot 1's dictionary. Use radio packets to exchange information.

python
# Inside act(): mutate the supplied dictionary.
memory["calls"] = memory.get("calls", 0) + 1
memory["last_seen_tick"] = sensors["tick"]

Write into memory; simply assigning memory = {"calls": 1} only replaces your local variable and does not update the saved dictionary. Module globals are recreated on each call, so a global counter is not persistent state. Keep helper functions and imports at module level, but put changing state in memory.

Think of memory as a notebook handed back to the same robot each tick:

CallBefore memory["calls"] = memory.get("calls", 0) + 1After
First{}{"calls": 1}
Second{"calls": 1}{"calls": 2}
Third{"calls": 2}{"calls": 3}

Assigning memory = {"calls": 1} starts a different local notebook instead. The runner still holds the original. Try the visible turning-counter experiment in Session 3 before adding saved ball tracks.

Memory must remain JSON-compatible: dictionaries with string keys, lists, strings, booleans, finite numbers, and None. Tuples round-trip as lists; integer dictionary keys become strings. Store values rather than objects such as classes, sets, functions, NumPy arrays or random generators. Limit memory to 16 KiB per robot. Keep recent summaries rather than appending every tick forever. If a call fails, its memory changes are not committed.

Memory survives goals and halftime. The field resets then, so old ball tracks and position estimates need resetting. Detect entry into kickoff or a changed half; save the previous phase/half in memory. Do not call setup yourself.

Complete example: a cautious ball chaser

Copy this entire block into one file such as my_bot.py. It remembers which way it last saw the ball, slows down to turn, and only requests a kick when broadly facing the attacking end. It is a starting point, not a strong coordinated team.

python
# robosoccer-kit: 0.3.0
import math


def wrap_angle(angle):
    return (angle + math.pi) % (2 * math.pi) - math.pi


def setup(info):
    return {"cruise": info["robot"]["cruise_speed"],
            "last_bearing": 0.0, "last_seen_tick": -1000}


def act(robot_id, sensors, memory):
    tick = sensors["tick"]
    if sensors["phase"] == "kickoff":
        memory["last_seen_tick"] = -1000
        return {}

    balls = [obj for obj in sensors["camera"] if obj["kind"] == "ball"]
    if not balls:
        recently_seen = tick - memory["last_seen_tick"] < 15
        if recently_seen:
            direction = 1.0 if memory["last_bearing"] >= 0 else -1.0
        else:
            # Different robots search in different directions.
            direction = 1.0 if robot_id % 2 == 0 else -1.0
        return {"move": (0.0, 1.3 * direction)}

    ball = min(balls, key=lambda obj: obj["distance"])
    bearing = ball["bearing"]
    memory["last_bearing"] = bearing
    memory["last_seen_tick"] = tick

    speed = memory["cruise"] * max(0.0, math.cos(bearing))
    if ball["distance"] < 0.45:
        speed *= 0.45
    if sensors["stamina"] < 0.2:
        speed = min(speed, 0.6)

    target_heading = 0.0 if sensors["attack_direction"] == 1 else math.pi
    goalward = abs(wrap_angle(target_heading - sensors["imu"]["heading"])) < 0.5
    kick = (0.85 if goalward and sensors["ball_in_kicker"]
            and sensors["kick_cooldown"] <= 0 else 0.0)
    return {"move": (speed, 4.0 * bearing), "kick": kick}

This example can still push the ball toward its own goal, collide with teammates, and shoot wide. Facing the attacking end is not the same as aiming at the goal centre from your current position. A stronger bot estimates its position, gets behind the ball, and aligns the robot, ball and opponent goal before accelerating.

Teamwork through the radio

Learn this in two versions. Start with the complete simple distance-broadcast bot in Session 4A of the lessons: one number, no saved history, no timestamps. Observe where it fails. The robust version below adds memory, observation timestamps, expiry and a shared tie-break rule. Read Session 4B's paper examples first; you do not need this helper in lesson 1.

Each robot may include one broadcast per tick. Packets reach each teammate after 2–5 ticks (about 67–167 ms at 30 Hz), with 10% loss per receiver. They never go to opponents or back to the sender. There are no acknowledgements. Your inbox contains deliveries for this tick, not a permanent message history. Save information you want to keep, expire it, and tolerate missing packets. In-flight packets are cleared at kickoff, but your own stored message history is not.

A delivered packet has this shape:

python
{"sender": 2, "sent_tick": 100, "received_tick": 104,
 "data": [1, 0.7, 100]}

This is a teammate's observation from tick 100 arriving at tick 104. Delays can make packets arrive out of observation order; keep the newest timestamp from each sender instead of blindly replacing a newer reading with an older one.

Decide on a numeric protocol. For example, packet type 1 can mean [1, distance_to_visible_ball, observation_tick]. The following is a helper to call from inside your own act; it returns a temporary striker ID and an optional packet to attach to the action:

python
def striker_bid(robot_id, sensors, memory, ball):
    tick = sensors["tick"]
    bids = memory.setdefault("bids", {})
    if sensors["phase"] == "kickoff":
        bids.clear()
        return robot_id, None
    for packet in sensors["messages"]:
        data = packet["data"]
        if len(data) == 3 and data[0] == 1:
            key = str(packet["sender"])
            previous = bids.get(key)
            if previous is None or data[2] >= previous[1]:
                bids[key] = [data[1], data[2]]
    bids[str(robot_id)] = [ball["distance"], tick] if ball else [1e6, tick]
    bids = {key: value for key, value in bids.items() if 0 <= tick - value[1] <= 10}
    memory["bids"] = bids
    winner = min(bids, key=lambda key: (bids[key][0], int(key)))
    packet = [1, ball["distance"], tick] if ball else None
    return int(winner), packet

Call it every tick, after finding ball (or None). If the packet is not None, assign action["broadcast"] = packet. Do not return (winner, packet) from act; act must return an action dictionary. Give non-strikers a separate support/defence behavior. Without it, bidding alone does not improve the team.

Stale bids can cause brief disagreement, especially after a robot loses sight of the ball. The example expires bids after ten ticks and uses robot ID to break ties. Improve it by announcing loss of sight or requiring a margin before changing roles. Distance bids compare distance only; they do not share ball position. One robot's bearing is in its own frame: another robot cannot steer toward that same bearing directly. Share global position estimates only after localization.

Rules that affect your strategy

The pitch is 9 × 6 m, with 1.8 m goal mouths. Walls and posts bounce objects; there are no throw-ins, ball possession locks, offside rules, or teleport actions. A goal requires the whole ball to cross the goal line through the mouth. Goals reset robots and the ball; a one-second kickoff freeze follows. Bot calls continue during that freeze, but movement/kicks do not take effect. Halftime swaps ends and resets the field. Use current attack direction, not a hard-coded assumption that your team always attacks right.

Only one robot may remain inside its own goal area. More than one for over two seconds awards the opponent a penalty goal. The default area is the final 1 m of your end, within 1.2 m either side of the centre line of the goal. Assigning all robots to sit in goal is a losing strategy.

Ball friction, kick strength, starting positions and sensor noise vary slightly by match seed. Test many seeds and both starting sides. The same source, seed, kit, config and options produce the same replay when execution stays within budgets. Keep logic independent of wall-clock time, files, network requests or unseeded randomness. Prefer standard-library random, which has engine-seeded per-robot streams; do not reseed it every call. Normal Python imports work in this trusted class, but extra dependencies must also exist on the teacher's machine. NumPy is optional and its RNG is not automatically seeded.

Test and debug your bot

Use this progression rather than judging one lucky goal:

  1. Run check after editing; fix all syntax, API and smoke-match errors.
  2. Run a short match with perfect sensors to check turn direction and kicking.
  3. Repeat with noisy sensors. Inspect a selected robot's vision and readings.
  4. Test full matches against each reference over many seeds and both sides.
  5. Change one behavior, then compare wins, draws, losses and goal difference.
sh
robosoccer run my_bot.py --vs random --half-seconds 5 --perfect-sensors --watch
robosoccer run my_bot.py --vs chaser --seed 3 --watch
robosoccer run my_bot.py --vs goalkeeper --matches 20 --seeds random --fast
robosoccer run my_bot.py --vs decent-team --matches 20 --seeds random --fast
robosoccer run my_bot.py --vs other_bot.py --seed 3
robosoccer league my_bots_folder/ --output league-replays/

For multi-match runs, the first bot starts on team 0 then team 1 with the same seed. Each next pair uses a new seed. --seeds 3,4 with --matches 4 selects two side-swapped pairs. Use the default full match for final comparisons; a very short experiment includes little play after kickoff.

Reference progression: random wanders; chaser pursues visible balls; goalkeeper adds localization and defensive roles; decent-team adds tracking, positioning and coordination. Study their installed Python files if useful.

sh
robosoccer run my_bot.py --vs chaser --seed 3 --debug-robot 1 --debug-log robot-1.jsonl
robosoccer watch replay.json
robosoccer benchmark
robosoccer --version

The debug log has one JSON object per tick with the chosen robot's sensors, memory before/after, action, error and traceback. --debug-robot selects a robot from your first bot even when sides swap. Replays default to replay.json; --replay experiment.json changes the output path. Timings and results go in the corresponding .summary.json; errors, if any, go in .errors.json. Printing is diagnostic output; it is not a communication channel.

Use replay play/pause, speed, tick stepping and scrubbing. Turn on vision cones, select a robot and compare detections with the spectator view. Ground-truth positions in a replay are for inspection and never appear in the bot's input. --fast omits saved sensor overlays but runs the same physics and sensor calls. Leave it off when debugging perception. Offline HTML can be opened without a server; the website's Matches page can also open a local JSON/gzip replay.

Timing and errors

LimitDefault
One act call50 ms wall time, including communication with the runner
Initial code execution plus setup2 seconds
Cumulative calls per team per match30 seconds
Whole match wall time120 seconds, including startup and simulation
Violations before forfeit5, counted across the team
Source / memory size64 KiB per file / 16 KiB per robot

An exception, invalid return, crash or call timeout results in a no-op and a violation. Repeated violations, exhausted team time or the whole-match deadline can end the match in a forfeit. A single-team forfeit records a 0–3 loss; a double forfeit records 0–0. It is still worth fixing the very first error. The organizer can change settings; the arena's displayed limits take precedence.

The 50 ms is a ceiling, not a target. Across 3 × 3,600 calls, a 30-second team budget averages about 2.8 ms per call, including setup overhead. Aim comfortably below that. Avoid expensive imports or computations at module level because the module is re-executed for every call. Start with short loops over detections, and bounded lists rather than full searches each tick. benchmark reports local speed and a reference-bot timing margin; it does not certify your own bot. Use your bot's per-robot mean/max timing report and test on the teacher's machine when timing is close. Hardware speed and load differ.

check plays two short two-second halves against chaser with seed 3. Passing it means the short check finished cleanly, not that every future state is correct or that your strategy will win. Test full matches too.

Upload and follow your results

  1. Sign in at the teacher's arena and open My bot.
  2. Upload the .py file or paste the whole source into the editor.
  3. Enter a team name and the version printed by robosoccer --version.
  4. Choose Check my bot, fix errors, then Submit to the league.
  5. Open Matches to see queued/running games and completed replays.
  6. Check the leaderboard or click your team to inspect its match history.

One submission version is active per account. A new upload replaces the active version, preserves the old source/history, and queues the new version against every active opponent, including the four references. It does not rerun games between other teams. The default is four matches per pair, with balanced sides. Standings count active versions: 3 points for a win, 1 for a draw, 0 for a loss; goal difference breaks points ties. Ratings are not used. Partial standings can be misleading while teams have played different numbers of matches.

Open My bot → Your bot history → Error log to read your own server errors. You can reload or download older code. Keep your own local copies of experiments. If the site warns that the tested kit is old, download the current kit, install it again and rerun your checks before updating the version field.

Common mistakes and fixes

SymptomCheck or fix
KeyError: 'ball'The ball is a camera list entry, not sensors["ball"]. Filter by kind.
IndexError from balls[0]Handle the empty list before indexing. Missing detections are normal.
Bot turns away from the ballPositive bearing and turn rate both mean left; use radians.
act() must return an action dictionaryReturn a dict on every branch, including when no ball is visible.
Counter resets every tickPut it in memory and mutate the supplied dict; do not use a global.
Memory serialization errorRemove sets/objects/NaN; use lists and finite JSON values.
Kick requests do nothingCheck true range, heading, cooldown and noisy flag; kick points along heading.
Own goalsGet behind the ball and align toward the current attacking goal before contact.
All three robots pile upAssign roles, add separation and communicate who is chasing.
Teammate radio steering is wrongBearings are sender-relative; share estimates in an agreed frame.
Works in half 1 onlyRead current attack_direction; reset stale tracks after kickoff.
Works with perfect sensors onlyAdd tracking, reject implausible jumps, and tolerate empty frames.
Timeout or forfeitRemove waiting/unbounded work; inspect error logs and timing reports.
New version has fewer pointsIts campaign starts fresh; wait until comparable matches finish.

A useful improvement path

Start by explaining your bot in plain language: what does it do with and without a visible ball? Then improve one part at a time. First make steering and kicking reliable. Next keep a short-lived ball estimate through missed detections. Add proximity avoidance and stamina pacing. Estimate position from landmarks and odometry so the robot can approach from the correct side. Finally add support, defender and striker roles with delayed communication and stale-message expiry. Keep a small experiment record with source version, opponent, seeds and results. The best evidence for an improvement is a repeated advantage across opponents and seeds, with clean execution and behavior you can explain in the replay.

For exact noise formulas and additional sensor details, read the API reference. The definitive settings are in robosoccer/config.toml inside the kit.