SteerPy is an autonomous driving playground that runs in the browser. Open the page, write Python, press run, watch the car react. That is the entire setup.
The problem
When I started learning robotics, the hardest part was not the theory. It was getting an environment up and running before actually try something. By the time ROS was installed, the Docker container was configured, and the simulator was compiling, I had forgotten what I wanted to learn.
I believe the best way to learn is to get your hands dirty. But that only works if the friction between “I want to try this” and “I am trying it” is close to zero.
This series uses SteerPy as a playground. Each post picks one concept, tells you exactly which file to change, and lets you see the result immediately. No setup required. Just you, a browser, and a car waiting to be broken.
Takeaway: If setup is the hard part, something is wrong.
Why it matters
Most robotics simulators are built for researchers who already know what they are doing. SteerPy is built for people who are still figuring it out. The physics is configurable but sane by default. The interface is a code editor and a running car. The feedback is immediate.
You can run it two ways:
- Browser: visit steerpy.withduong.com directly, nothing to install
- Local: clone lesun90/steerpy and run it from your machine
For this series, the browser version is all you need.
Takeaway: Open browser, write code, car moves. Zero friction.
How it works
SteerPy gives you two Python files to edit. Everything else runs automatically.
controller.py is called every simulation step. It receives the car’s current state and the trajectory from the planner, and returns [throttle, steer]. Throttle is 0 to 1, steer is -1 (full left) to +1 (full right).
# controller.py
# controller(car, trajectory) -> [throttle, steer]
#
# Available state:
# car.x, car.y, car.angle, car.speed
# car.length, car.width, car.wheelbase
# car.min_speed, car.max_speed
# car.steer_angle, car.vx, car.vy
def controller(car, trajectory):
if not trajectory:
return [0.0, 0.0]
throttle_cmd = 0.0
steer_cmd = 0.0
return [throttle_cmd, steer_cmd]
Right now this does nothing. The car sits still. That is your starting point.
planner.py decides where the car should go. It receives the car’s state and a world model containing road waypoints, obstacle positions, and sensor readings. It returns a list of (x, y, target_speed) points for the controller to track.
# planner.py
# planner(car, world_model) -> [(x, y, target_speed), ...]
#
# World model:
# world_model.road_data.waypoints # road centerline points
# world_model.road_data.width # lane width in meters
# world_model.obstacles # list of {x, y, length, width, heading_deg}
# world_model.loop # True if the track loops
#
# Sensors (meters to nearest obstacle, -1 = clear):
# car.sensors.front, car.sensors.rear
# car.sensors.left, car.sensors.right
# car.sensors.lidar # N-beam list, index 0 = front, increasing CCW
def planner(car, world_model=None):
return []
No trajectory returned means the controller has nothing to aim at. The car stops. Both files start empty on purpose: every concept in this series is you filling them in from scratch.
The simulation loop is: planner picks waypoints, controller steers toward them, car moves, repeat. Hit Ctrl+S to apply your changes without restarting.
SteerPy also supports multiple physics models (kinematic, bicycle, Ackermann, drift) and configurable sensors including six-direction distance sensors and LiDAR. You will not need any of that yet, but it is there when a post calls for it.
Takeaway: Two empty functions. That is your starting point for everything in this series.
Hello world
Before diving into any theory, try this. Switch to the controller.py tab and set a fixed throttle and steer:
def controller(car, trajectory):
throttle_cmd = 0.5 # half throttle
steer_cmd = 0.2 # slight right
return [throttle_cmd, steer_cmd]
Hit Ctrl+S. The car moves, drifts right, and eventually leaves the road. That is expected. You just confirmed the controls work.
Now switch to the planner.py tab and return a hardcoded straight-line trajectory ahead of the car:
def planner(car, world_model=None):
# Build a simple straight-line path 5 meters ahead, 10 points
import math
points = []
for i in range(1, 11):
x = car.x + i * math.cos(math.radians(car.angle))
y = car.y + i * math.sin(math.radians(car.angle))
speed = 3.0 # target speed in m/s
points.append((x, y, speed))
return points
The controller now has a trajectory to follow. It still ignores it (both commands are hardcoded), but you can see the waypoints appear on screen. In the next post you will wire the two together so the controller actually steers toward them.
Takeaway: Break it first. You learn more from a car going off-road than from reading the docs.
Changing the world
By default SteerPy drops you into a randomly generated track. If you want to test a specific situation (a sharp corner, a narrow gap between obstacles, a straight highway) you need to define the world yourself. Switch to the world_config.py tab.
The entire world is three things: a list of waypoints that define the road centerline, a road width, and a list of obstacles.
# world_config.py
# Units are meters.
waypoints = [
(0, 0),
(20, 0),
(20, 20),
(0, 20),
]
road_width = 8
sample_distance = 1
loop = True
# obstacles: (cx, cy, length, width, heading_deg)
obstacles = []
This gives you a square track, 8 meters wide, no obstacles. Hit Ctrl+S. The car spawns at the first waypoint and the road follows the square. Try driving it. Notice that tight 90-degree corners are harder to hit than they look.
Waypoints are the road centerline, in order. SteerPy interpolates a smooth spline between them, so you do not need hundreds of points. Six to ten is usually enough for a realistic track. The car spawns at the first waypoint facing the second one.
# A gentle S-curve
waypoints = [
(0, 0),
(30, 0),
(50, 15),
(70, 0),
(100, 0),
]
road_width = 10
loop = False
Set loop = False and the road ends at the last waypoint. Useful for straight-line or lane-change scenarios where you do not want the car chasing its own tail.
Obstacles are rectangles placed anywhere in the world. Each one takes five numbers: center x, center y, length (along heading), width (perpendicular), and heading in degrees.
# Two parked cars blocking the right lane
obstacles = [
(40, -2, 4.5, 2.0, 0), # parked parallel to road
(60, -2, 4.5, 2.0, 0), # another one 20 m ahead
]
Heading 0 means the obstacle is aligned with the x-axis. Rotate it to match the road angle if the road is diagonal. The obstacle dimensions do not have to be car-sized. Make a wall with (50, 0, 1, 20, 90) and watch your planner (eventually) figure it out.
A useful starting scenario for testing obstacle avoidance: one wide road, one obstacle in the center.
waypoints = [
(0, 0),
(100, 0),
]
road_width = 12
loop = False
obstacles = [
(50, 0, 4.5, 2.0, 0), # dead center, hard to ignore
]
The car will drive straight into it until you teach the planner to go around. That is the point.
Takeaway: Waypoints define the road, obstacles define the problem. Change both until you find the scenario that breaks your controller.