The fastest lap through the corridor
Teach a robot, by trial and error, to drive the 60 cm corridor with its sharp right-hand bend as fast as it can without touching the walls. Everything runs on your own PC: the Botopia simulator in the background, your Python code in front. About an hour, of which a few minutes of training.
1The goal
The robot is the skid-steer robot: four fixed wheels, it
steers by turning the left and right side at different speeds. It gets two commands, exactly like
the W/A/S/D keys: fwd (−1 … 1, backward / forward) and turn (−1 … 1,
right / left). It starts in front of the corridor. The lap is done when it is past the bend.
This is what you will end up with: the policy that PPO learned, driving its fastest lap.
The trained robot is not much faster than a good hand-written controller, and that is fine: the point is that nobody told it how to take the bend. It found out by itself, from a single number per step that says “good” or “bad”.
2What you need
- The API repository:
git clone https://github.com/mdequanter/robotSimulatorAPI.git(API server, Python client and the tutorial scripts; see Getting started). The API server fetches the simulator pages from this site itself, so you do not need PHP or a web server. - Node.js 22 or newer (
node --version). Nonpm installneeded. - Google Chrome (or Chromium / Edge). The server starts it invisibly in the background.
- Python 3.10 or newer with three packages:
pip install stable-baselines3 gymnasium pillow
stable-baselines3 brings PyTorch along (the CPU version is enough; no graphics card
needed). pillow is only for saving a lap as an animated GIF.
The two Python files of this tutorial are in python/ of that repository:
botopia.py | client for the simulator API (standard library only) |
corridor_rl.py | everything in this tutorial: the environment, a hand-written driver, training, testing |
3Start the simulator
Open a terminal in the robotSimulatorAPI folder and start the API server:
node server.mjs
Botopia simulator API on http://localhost:8780/api/ (simulators: http://localhost:8780/)
Chrome headless, robots: wheeled, bike, synchro, mecanum, kiwi, swerve, tracked, skid, ...
Leave this terminal open. Every environment you create is one simulator page in that Chrome,
running in lock-step: simulated time only moves when you call step.
So training is not tied to the clock: one step of 0.1 s simulated time takes about 1 ms.
Want to watch? Start it with --show: Chrome opens visibly with a page
“Botopia simulator API is running”. Every environment your code creates then opens in its own tab
(the newest comes to the front) and is redrawn every step. Training is slower that way, so use it
for watching, not for training.
4Drive it by hand
Before any learning, try the API yourself. In a second terminal, start Python from python:
from botopia import BotopiaAPI
api = BotopiaAPI("http://localhost:8780")
env = api.create("skid", "corridor") # a skid-steer robot in front of the corridor
eid = env["id"]
print(env["info"]["drive"]) # ['fwd', 'turn']
for i in range(30): # 30 steps of 0.1 s: full throttle
r = api.step(eid, {"drive": {"fwd": 1.0, "turn": 0.0}}, dt=0.1)
if r["events"]:
print(r["obs"]["t"], r["events"])
print(r["obs"]["pose"])
open("view.jpg", "wb").write(api.frame(eid)) # what the camera sees now
Every step returns an observation (obs: position and heading, speeds,
IMU, every motor with PWM, encoder and current, progress in the lane) and the
events of that step. Driving straight on, you will soon see a
collision event: the robot reached the far wall of the bend. Open
view.jpg to see it standing there.
Positions are in metres. x runs along the lanes, z sideways (to the
robot's right), yaw is the heading in radians, positive to the left. All fields are
described in the API documentation.
5The task as an RL problem
Reinforcement learning needs four things: what the robot sees (observation), what it can do (action), what is good (reward) and when an attempt is over (episode). Those choices are most of the work: the learning algorithm itself comes off the shelf.
Action: 2 numbers
[fwd, turn], each −1 … 1, sent as a drive command. The robot's own
controller turns them into PWM for its four motors, just like when you press the keys. A new
decision every 0.1 s.
Observation: 10 numbers, all roughly −1 … 1
| # | What | Why the robot needs it |
|---|---|---|
| 0 | progress s / 3 m | where in the lap it is |
| 1 | sideways offset e / 0.3 m (walls at ±1) | how close to a wall |
| 2, 3 | sin, cos of the heading error | is it pointing along the corridor? |
| 4, 5 | sin, cos of the heading error to the line 0.5 m further on | sees the bend coming |
| 6 | distance to the bend / 1.5 m | when to brake and steer |
| 7 | forward speed / 0.6 m/s | how much it can still slow down |
| 8 | turn rate / 2 rad/s | is it already turning? |
| 9 | wall touched in the last step (0 / 1) |
Angles as sin and cos, so that −179° and +179° look alike to the network; everything scaled to about −1 … 1, because neural networks learn badly from numbers of very different sizes.
Reward per step
reward = 10 × (metres gained along the centre line)
− 0.1 # time penalty: every step costs something
+ 10 at the goal
− 5 when it touches a wall (and the attempt ends)
The progress term says “forward is good”, the time penalty says “and sooner is better”, the goal bonus makes finishing worth it and the wall penalty makes crashing expensive.
Episode
An attempt ends at the goal, when the robot touches a wall, or after 200 steps (20 s: too slow).
Then the robot is put back at the start. A slower robot needs more time: --max-time 40
(works for baseline, train, eval and watch).
6The environment class
Learning libraries expect the Gymnasium interface:
a class with reset() and step(action). CorridorEnv in
corridor_rl.py wraps one simulator. The heart of it, shortened:
class CorridorEnv(gym.Env):
def __init__(self, robot="skid", url="http://localhost:8780", dt=0.1, max_steps=200,
end_on_hit=True, time_penalty=0.1):
self.api = BotopiaAPI(url)
self.id = self.api.create(robot, "corridor")["id"] # one simulator page
self.action_space = spaces.Box(-1.0, 1.0, shape=(2,)) # fwd, turn
self.observation_space = spaces.Box(-5.0, 5.0, shape=(10,))
...
def reset(self, seed=None, options=None):
self.obs = self.api.reset(self.id, lane="corridor", seed=seed) # back to the start
self.steps, self.s = 0, 0.0
return self._features(self.obs, False), {}
def step(self, action):
a = [float(np.clip(v, -1, 1)) for v in action]
r = self.api.step(self.id, {"drive": {"fwd": a[0], "turn": a[1]}}, dt=self.dt)
self.obs, self.steps = r["obs"], self.steps + 1
p = self.obs["pose"]
s, _, _ = path_frame(p["x"], p["z"]) # progress along the centre line
hit = any(ev["type"] in ("collision", "blocked", "stall") for ev in r["events"])
goal = p["z"] >= GOAL_Z
reward = 10 * (s - self.s) - self.time_penalty + (10.0 if goal else 0.0)
if hit:
reward -= 5.0 if self.end_on_hit else 1.0
self.s = s
terminated = goal or (hit and self.end_on_hit)
truncated = self.steps >= self.max_steps and not terminated
return self._features(self.obs, hit), reward, terminated, truncated, info
path_frame(x, z) finds the nearest point on the centre line (two straight pieces)
and returns s, e and the direction of the line there. _features
turns that, together with the speeds from the observation, into the 10 numbers of the table above.
Why not simply the API's own observation? The simulator reports where the robot is in the world, but the corridor itself is not in it. Giving the robot its position relative to the centre line saves it from having to learn the map. On a real robot you would get something similar from distance sensors or a camera.
7The time to beat
Before training, always build a simple non-learning solution. It tells you whether the task is
possible at all and what “good” is. baseline_action aims at a point 0.5 m ahead on the
centre line and slows down when it has to turn hard:
python python/corridor_rl.py baseline
Hand-written driver: 4.90 s, wall hits 0, total reward 31.0
Full throttle, no steering: did not finish in 20 s, wall hits 2, total reward -6.8
8Train with PPO
PPO (Proximal Policy Optimization) is a solid default for continuous actions such as throttle and steering. Its policy is a small neural network (two layers of 64) that takes the 10 observation numbers and outputs the two actions, plus a bit of randomness so it keeps trying new things. After every 2048 steps it nudges the network towards whatever earned more reward.
python python/corridor_rl.py train
-----------------------------------------
| rollout/ | |
| ep_len_mean | 49.6 |
| ep_rew_mean | 23.6 |
| time/ | |
| total_timesteps | 61440 |
...
Saved corridor_ppo.zip after 162 s of training
Eight simulators gather experience at the same time (--envs 8): every step is one batch
call to the API, and the server steps all eight Chrome tabs in parallel, each on its own CPU core.
The code that sets it up is short:
venv = VecMonitor(make_vec_env(8, "skid", "http://localhost:8780")) # 8 simulators, one API call per step
model = PPO("MlpPolicy", venv, n_steps=256, batch_size=256, learning_rate=3e-4, gamma=0.99,
ent_coef=0.003, verbose=1, seed=1, device="cpu")
model.learn(total_timesteps=150_000)
model.save("corridor_ppo.zip")
The curve and the table below come from an earlier run with 4 simulators stepped one by one (6½ minutes, best lap 4.80 s). With the faster set-up our run took 2.7 minutes and drove 5.00 s: RL results always differ a little from run to run.
Reading the log
Two lines matter: ep_rew_mean, the average reward of the last attempts, and
ep_len_mean, how many steps they lasted. Below is the curve from the run that produced
the video at the top.
| Steps | ep_len_mean | ep_rew_mean | What is happening |
|---|---|---|---|
| 2 000 | 125 | −12.7 | random driving, often until the 20 s limit |
| 10 000 | 37 | −1.7 | drives off quickly and hits a wall: short, cheap attempts |
| 30 000 | 33 | +6.0 | reaches the bend; first laps finished |
| 60 000 | 50 | +23.6 | finishes nearly every attempt |
| 150 000 | 48 | +28.4 | fewer steps per lap: it got faster |
Notice the dip in ep_len_mean early on: the robot first learns to drive, and
crashes sooner. Longer attempts later are a good sign (it gets further), and at the very end the
attempts get shorter again because it finishes faster.
Faster training: CPU cores, not the GPU
Where does the training time go? Measured on a laptop with 16 CPU cores and an RTX 5070: about 90 % simulating the robot, about 10 % updating the neural network.
| Simulators | stepped one by one | all at once (batch call) |
|---|---|---|
| 1 | 466 steps/s | 739 steps/s |
| 4 | 515 steps/s | 2 124 steps/s |
| 8 | 379 steps/s | 3 278 steps/s |
So the way to train faster is more simulators in one batch call:
--envs, about one per two CPU cores. A GPU only speeds up the network part, and for a
network this small (2 × 64 neurons) copying the data to the GPU and back costs more than it
saves: stable-baselines3 itself advises to run PPO with an MlpPolicy on the CPU.
A GPU does pay off for big networks, for example when the robot learns from camera images
(CnnPolicy). Then install the CUDA build of PyTorch (pick your CUDA version on
pytorch.org; RTX 50-series cards need CUDA 12.8
or newer) and train with --device cuda:
pip uninstall torch
pip install torch --index-url https://download.pytorch.org/whl/cu128
python -c "import torch; print(torch.cuda.is_available())" # True = the GPU is usable
python python/corridor_rl.py train --device cuda
9Test and watch
Drive test laps without the training randomness (deterministic=True):
python python/corridor_rl.py eval
PPO, run 1: 4.80 s, wall hits 0, total reward 31.3
PPO, run 2: 4.80 s, wall hits 0, total reward 31.3
PPO, run 3: 4.80 s, wall hits 0, total reward 31.3
Three identical times: the simulator is deterministic, the same actions always give the same result. For the test laps a wall touch does not end the attempt; it is counted instead.
Two ways to watch:
# 1. save one lap as an animated GIF
python python/corridor_rl.py watch --gif lap.gif
# 2. live: restart the server with a visible Chrome, then drive one lap in real time
node server.mjs --show
python python/corridor_rl.py watch
10Experiments
Now the fun part. Change one thing at a time, train again, and compare with eval.
| Try | How | What we saw / what to look for |
|---|---|---|
| Less time pressure | --time-penalty 0.05 |
Speed is worth less: our run finished cleanly but slower (6.4 s after 60 000 steps). |
| More time pressure | --time-penalty 0.3 |
Does it take more risk at the bend? More wall touches? |
| Keep going after a wall touch | CorridorEnv(..., end_on_hit=False) |
Our first attempt: the robot pushed against walls for 25 s at a time and had learned little after 60 000 steps. Ending the attempt is a strong lesson. |
| Shorter training | --steps 60000 | About 2 minutes. Does it finish? How fast? |
| Another robot | --robot tracked, 4wd, ackermann, whegs |
Each drives differently: tracks slip, the car can't turn on the spot. Run baseline first. |
| Blind for the bend | remove numbers 4–6 from _features |
Without seeing the bend coming, can it still brake in time? |
| Slower decisions | CorridorEnv(..., dt=0.2) | Fewer, bigger steps: faster training, coarser driving. |
| Learn from the camera | self.api.frame(self.id, view="robot", width=84) |
Every robot has a front camera (the picture in the corner of the simulator). Feed its images instead of the 10 numbers to a CnnPolicy: much harder and slower to learn, but no map needed. This is where a GPU starts to pay off. |
Next: tutorial 2, where the robot gets no position at all, only its camera picture, and learns to drive a whole track from that.
Challenge: get under 4.5 s without touching a wall. Hint: the reward, the observation and the training length all matter more than PPO's settings.
Going further: action_mode “motors” in botopia.py lets the network drive the
four motors' PWM directly, without the robot's own controller. And every other lane (stairs, gaps,
ramps…) and robot works with the same API.
11Troubleshooting
| Problem | Solution |
|---|---|
ConnectionRefusedError in Python | The server is not running, or on another port. Start node server.mjs and leave it open. |
No trained model 'corridor_ppo.zip' (older version: corridor_ppo.zip.zip not found) | Run train first. The model is saved in the folder you run the command from, so run eval / watch from that same folder (or pass --model path/to/corridor_ppo.zip). |
| Server: Chrome not found | node server.mjs --chrome "C:\Program Files\Google\Chrome\Application\chrome.exe" (or set the CHROME environment variable). |
| Port 8780 already in use | --port 8790 on the server and --url http://localhost:8790 on corridor_rl.py. |
needs a robot that drives with fwd + turn | That robot moves differently (synchro: fwd + strafe). Pick one with fwd + turn, or adapt the action space. |
| Training is much slower than here | Is the server running with --show? Restart it without. On a PC with few CPU cores use fewer --envs (about one per two cores). |
| Results differ from this page | Small differences are normal (other PyTorch version, other PC). If it never finishes, train longer. |