When the world has too many dimensions to grid, you stop drawing the whole map and start probing it. Random samples, a nearest-neighbor, a step, a collision check — and a tree that rushes outward to fill the space.
In Lesson 3 you planned a path by chopping the world into a grid and running A* over the cells. For a robot rolling on a floor, the world is two-dimensional — (x, y) — so a 100×100 grid is 10,000 cells. A* sweeps through that in milliseconds. Beautiful. So why not do it forever?
Because the floor is the easy case. Consider a 6-DOF robot arm — the kind welding car bodies or stacking dishes. Its configuration isn't (x, y); it is six joint angles (θ1, θ2, θ3, θ4, θ5, θ6). To plan a motion you must search the space of all possible joint-angle combinations — the configuration space, or C-space, that Lesson 3 introduced.
Here is the catastrophe. Suppose we discretize each joint into just 100 buckets — coarse, barely 3.6° resolution. In 2D that's 100² = 10,000 cells. In 6D it's 100⁸ — one trillion cells. A trillion. If each cell needed one byte, that's a terabyte of memory just to store the empty grid, before you've checked a single one for collision. This explosion has a name: the curse of dimensionality. The number of grid cells grows as (resolution)dimension — the dimension sits in the exponent, so each extra joint multiplies the cost.
Watch the explosion happen. The widget below discretizes each dimension into a fixed number of buckets and shows the total cell count climbing as you add dimensions. The bars are on a logarithmic scale — if they were linear, the high-dimensional bars would be taller than the solar system.
Set the per-axis resolution, then watch the total cell count as the robot gains degrees of freedom. A 2D mobile robot is comfortable. A 6-DOF arm needs more cells than there are grains of sand on Earth. The bars are log-scale — each gridline is ×1000.
Notice the shape: the bars don't grow gently, they grow geometrically. Going from 2D to 3D doesn't add a fixed amount — it multiplies by the resolution. At 20 buckets per axis, a 6-DOF arm is 20⁶ = 64 million cells; bump to 50 buckets and it's 50⁶ ≈ 15.6 billion. The grid never had a chance.
And it gets worse: A* must also collision-check cells as it visits them, and collision checking — "does the arm in this pose touch any obstacle?" — is itself expensive. Visiting even a fraction of a trillion cells, each requiring a geometry test, is hopeless in real time.
That is the entire idea of this lesson. We trade completeness (a grid is guaranteed to find a path if one exists at that resolution) for tractability (we explore a high-dimensional space without ever enumerating it). In exchange we get a weaker guarantee — probabilistic completeness, which we'll define carefully in Chapter 5 — but we get to plan for robots that grids simply cannot handle.
The grid's fatal flaw is that it insists on visiting every cell, free or occupied, near the path or far from it. But to find a route you don't need a complete map — you need a sparse set of stepping stones that happen to connect start to goal through free space. Sampling-based planning builds exactly that: a thin scaffold of free configurations, ignoring the vast empty regions a grid would dutifully enumerate.
The recipe is startlingly simple. It rests on three primitives, each cheap and each independent of dimension:
True/False. Crucially, it works in the workspace (the physical 2D/3D world where the obstacles live), so it never has to reason about the high-dimensional C-space directly.Why does random sampling cover the space at all? Because uniform random points, given enough of them, eventually land in every region that has nonzero volume. A narrow corridor is harder to hit (it has small volume), but a wide-open room fills in fast. We never enumerate the free space; we sample it densely enough to stitch together a path.
Play with the sampler below. Each click of sample throws down a batch of random points; green ones are free (the collision checker accepted them), red ones landed in an obstacle and get discarded. Watch how quickly the free region gets populated — and how the obstacle interiors stay empty because every sample there is rejected.
Press sample 30 to scatter 30 random configurations. Green = collision-free (kept); faint red = inside an obstacle (rejected). The obstacles never fill in; the free space does. Notice you never had to describe the obstacles — you only had to test points against them.
Here is the collision check in code — the "black box" the whole field is built around. For point-vs-axis-aligned-rectangle obstacles it is a few comparisons:
python import numpy as np # Each obstacle is a box: (xmin, ymin, xmax, ymax). obstacles = [(2,2,5,7), (6,1,8,5)] def is_free(x): # x is a configuration, here a 2-vector (px, py). px, py = x for (xmn, ymn, xmx, ymx) in obstacles: if xmn <= px <= xmx and ymn <= py <= ymx: return False # inside a box -> in collision return True # touched nothing -> free def sample_free(bounds): # keep drawing until we get a free configuration while True: x = np.random.uniform(bounds[0], bounds[1]) # uniform in the box if is_free(x): return x
That is_free function is the only thing that ever touches the obstacle geometry. Everything else — PRM, RRT, RRT* — treats it as a sealed oracle. Swap in a 3D mesh-collision library or a full arm-vs-environment check and the planners above it don't change a line. That black-box abstraction is exactly why one sampling algorithm serves wheeled robots, drones, and arms alike.
is_free) play in a sampling-based planner?The first great sampling-based planner is the Probabilistic Roadmap (PRM) [Kavraki et al., 1996]. Its philosophy: pay a big up-front cost to build a reusable map of the free space — a roadmap — then answer many start–goal queries cheaply against that one map. PRM is therefore a multi-query planner: build the roadmap once, reuse it as long as the environment doesn't change.
A PRM produces a graph G = (V, E) embedded in the free space. The vertices V are collision-free configurations; the edges E are collision-free straight-line connections between nearby vertices. Once built, any planning query becomes a graph search — A* or Dijkstra — over this roadmap.
Stanford's Lecture 6 gives the preprocessing step. Every symbol is defined as it appears:
Let's connect two sampled vertices. Suppose the sampler produced A = (1, 1) and B = (4, 5), and we picked r = 6. First, are they within radius?
5 ≤ 6, so they're candidates — we proceed to check the segment. Edge validation is done by densely sampling the line and collision-checking each point. Walk from A to B in, say, 5 steps; at fraction t the point is A + t(B − A):
| t | point on segment | is_free? |
|---|---|---|
| 0.00 | (1.0, 1.0) | free |
| 0.25 | (1.75, 2.0) | free |
| 0.50 | (2.5, 3.0) | free |
| 0.75 | (3.25, 4.0) | free |
| 1.00 | (4.0, 5.0) | free |
Every sampled point is free, so the edge A–B is added. If even one intermediate point had been inside an obstacle, we'd reject the edge — the line "passes through a wall." (Denser sampling catches thinner obstacles; the trade-off is more collision checks per edge.)
Build a roadmap yourself below. Press sample to scatter free vertices, connect to wire up neighbors within the radius, and query to find a path from start (orange) to goal (green) by searching the roadmap.
1) sample free configurations. 2) connect pairs within radius r whose connecting line is free. 3) query for a path start→goal over the roadmap. Crank r down and watch the roadmap fragment so the query fails — even though a path obviously exists.
The whole preprocessing step is a couple of loops — sample-and-filter, then connect-within-radius:
python import numpy as np def build_prm(n, r, bounds): # Step 1: sample n free vertices V = [] while len(V) < n: x = np.random.uniform(bounds[0], bounds[1]) if is_free(x): V.append(x) V = np.array(V) # Step 2: connect pairs within radius r whose segment is free E = [] for i in range(n): for j in range(i+1, n): # each unordered pair once if np.linalg.norm(V[i]-V[j]) <= r and segment_free(V[i], V[j]): E.append((i, j)) return V, E def segment_free(a, b, steps=20): # densely sample the line a->b and collision-check every point for t in np.linspace(0, 1, steps): if not is_free(a + t*(b-a)): return False return True
After build_prm, a query is just: add s and t as temporary vertices, connect them to nearby roadmap nodes, and run A* over (V, E) — the very algorithm from Lesson 3, now on a sparse graph instead of a dense grid.
For a single query in a possibly-changing world, building a whole roadmap is wasteful. Rapidly-exploring Random Trees (RRT) [LaValle, 1998] takes the opposite tack: grow a single tree T, rooted at the start configuration, that reaches incrementally toward unexplored regions until it touches the goal. A tree is a graph with exactly one path between any two vertices — so once the goal is in the tree, the path back to the start is unique and instantly read off.
Each iteration adds (at most) one node by four steps. Define each symbol the first time it appears:
Let's run a single RRT iteration by hand. Suppose the tree currently has three nodes:
Step 1 — Sample. The random sampler returns xrand = (5.0, 4.0).
Step 2 — Nearest. Compute the Euclidean distance from xrand to each tree node:
The smallest is d(x2) ≈ 3.64, so xnear = x2 = (1.5, 3.0).
Step 3 — Steer with η = 1.0. First the unit direction from xnear toward xrand. The displacement is (5−1.5, 4−3) = (3.5, 1.0) with length √(3.5²+1²) = √13.25 ≈ 3.64. The unit vector is:
Step distance η = 1.0 along that direction, added to xnear:
Step 4 — Collision-check & add. Test the segment from (1.5, 3.0) to (2.462, 3.275). Suppose is_free returns True all along it. Then we add the vertex xnew = (2.462, 3.275) and the edge (x2, xnew). The tree now has four nodes, and it just reached one step closer to the bottom-right region the sample came from. One iteration, done.
Step through it live. sample drops a random target and highlights the nearest node and the η-step; auto-grow repeats until the tree spans the space. Notice the tree's reach: it preferentially pushes into the empty regions.
Press step to perform one iteration: a faint star is the random sample, the highlighted node is xnear, and the new short edge is the η-step toward it. auto-grow runs many steps. Watch the tree rush outward to fill the open space — that is the “rapidly-exploring” in RRT.
This is RRT's secret and the reason for its name. Imagine drawing the Voronoi diagram of the tree's vertices — the partition of space where each region (a Voronoi cell) contains all points closer to that one vertex than to any other. Vertices on the frontier of the tree, bordering big empty regions, own large Voronoi cells; vertices buried inside the tree own tiny ones.
Now: a uniformly random sample is most likely to land in a large Voronoi cell — there's simply more area there. And whichever cell it lands in, that cell's frontier vertex becomes xnear and gets extended. So the tree is biased to grow from its frontier into the unexplored void — automatically, with no special machinery. The exploration is a free side-effect of "sample uniformly, extend the nearest node."
python def rrt_extend(T, bounds, eta): x_rand = np.random.uniform(bounds[0], bounds[1]) # 1. sample i_near = nearest(T.nodes, x_rand) # 2. nearest tree vertex x_near = T.nodes[i_near] x_new = steer(x_near, x_rand, eta) # 3. step eta toward x_rand if segment_free(x_near, x_new): # 4. collision-check & add T.add_node(x_new, parent=i_near) return T def nearest(nodes, q): d = np.linalg.norm(nodes - q, axis=1) # distance to every node return int(np.argmin(d)) # index of the closest def steer(a, b, eta): v = b - a dist = np.linalg.norm(v) if dist <= eta: return b # close enough: land on b return a + (v/dist)*eta # else one unit step toward b
That steer function is exactly the hand arithmetic above: normalize the displacement to a unit vector, scale by η, add to xnear. The whole RRT is this 4-line loop repeated until the goal is reached.
Pure RRT explores magnificently but aimlessly — it fills the space evenly, with no preference for the goal. Left alone it might wander for thousands of iterations before a sample happens to fall near the target. We can speed this up enormously with one tiny change: goal biasing.
The trick: with a small probability p on each iteration, instead of sampling randomly, set xrand = xgoal. The rest of the iteration is unchanged — find the nearest tree node, step η toward the goal, collision-check. So p×100% of the time the tree is actively reaching for the goal; the other (1−p)×100% it keeps exploring so it doesn't get trapped.
Stanford's HW2 pseudocode (Algorithm 2) writes it exactly. Each iteration:
With p = 0.05 and the coin flips z = [0.71, 0.03, 0.88, 0.42, 0.01] over five iterations:
| iter | z | z < 0.05? | xrand is… |
|---|---|---|---|
| 1 | 0.71 | no | random sample |
| 2 | 0.03 | yes | the goal |
| 3 | 0.88 | no | random sample |
| 4 | 0.42 | no | random sample |
| 5 | 0.01 | yes | the goal |
Two of five iterations aimed at the goal here — higher than the 5% long-run average, just sampling noise over a short run. On average about 1 in 20 iterations pulls toward the goal; the other 19 explore.
Two termination conditions, from the same pseudocode:
Drag the goal-bias slider below and watch the tree's personality change. At p = 0 it's a pure explorer, filling space evenly. Crank p up and the tree visibly leans toward the goal — faster, but watch it occasionally get hung up on an obstacle when the bias is too aggressive.
Set the goal-bias p, then grow. At p=0 the tree explores everywhere; raise p and it leans hard toward the green goal. The counter shows total iterations and how many were goal-directed. Find the sweet spot where it reaches the goal in the fewest iterations.
python def rrt_plan(x_init, x_goal, bounds, eta, p, K): T = Tree(x_init) for k in range(K): # goal biasing: with prob p, aim straight at the goal if np.random.rand() < p: x_rand = x_goal else: x_rand = np.random.uniform(bounds[0], bounds[1]) i_near = nearest(T.nodes, x_rand) x_new = steer(T.nodes[i_near], x_rand, eta) if segment_free(T.nodes[i_near], x_new): j = T.add_node(x_new, parent=i_near) if np.linalg.norm(x_new - x_goal) < eta: # reached! return T.path_to(j) # walk parents back to root return None # Failure within K iterations
RRT finds paths fast. But what kind of guarantee comes with that speed? This is where we must be careful, because sampling planners trade away the iron-clad completeness of a grid for a softer, probabilistic promise.
A planner is complete if it always finds a path when one exists, and reports failure when none does. RRT is not complete in that strong sense. Instead it is probabilistically complete:
Compare the three guarantee levels you now know:
| Planner | Guarantee | Catch |
|---|---|---|
| Grid A* (Lesson 3) | Resolution-complete | finds a path if one exists at that grid resolution; dies in high-D |
| RRT | Probabilistically complete | P(found) → 1 as n → ∞; can't prove no-path |
| RRT* (next chapter) | Asymptotically optimal | path cost → optimal as n → ∞; even slower per node |
Here is the sharper limitation. Even when RRT does find a path, that path is usually far from optimal — jagged, meandering, much longer than the straight-line shortest route. Karaman and Frazzoli (2011) proved it formally: RRT can return arbitrarily-bad paths with non-negligible probability. The classic picture is a planner that, given a long way around and a short way through, prefers the long red detour.
Why? Three reasons baked into the algorithm:
See the jaggedness for yourself. The widget grows an RRT to a goal and draws the raw path it found; the dotted grey line is the straight-line ideal. The gap between them is RRT's suboptimality — and it persists no matter how long you grow the tree.
Press find path to grow an RRT until it reaches the goal, then highlight the path it returns (orange) against the straight-line ideal (dotted). The path cost vs. straight-line distance is shown. Re-run a few times — the path is different every time, and always longer than ideal.
So we have a planner that is fast and probabilistically complete but produces ugly paths. Two ways out, which fill the rest of the lesson: fix the path afterward (smoothing, Chapter 7) or fix the tree as it grows so it converges to the optimum (RRT*, next).
RRT's suboptimality came from one place: once a node's parent is set, it's never reconsidered. RRT* [Karaman & Frazzoli, 2011] fixes exactly that. It adds two local-optimization steps to every iteration so that the tree's connections keep improving as samples accumulate — making RRT* asymptotically optimal: the path cost converges to the true optimum as n → ∞.
The new ingredient is a cost-to-come stored at every node. Write it c(x): the total path length from the root to x along the tree. It satisfies c(x) = c(parent) + ‖x − parent‖ — each node's cost is its parent's cost plus the edge length. RRT* uses c to make smarter choices.
RRT* runs the same sample → nearest → steer → collision-check as RRT, then inserts two extra steps before moving on:
A new node xnew = (4.0, 4.0) has appeared. Within radius r = 2.5 sit three existing nodes, each with a known cost-to-come:
| candidate parent | position | c (cost-to-come) | ‖xnew−parent‖ | c + edge |
|---|---|---|---|---|
| xnear | (3.0, 4.0) | 6.0 | 1.00 | 7.00 |
| xa | (4.0, 2.5) | 4.5 | 1.50 | 6.00 |
| xb | (2.5, 3.0) | 5.0 | 1.80 | 6.80 |
Plain RRT would parent xnew to the nearest node, xnear (distance 1.00), giving cost-to-come 6.0 + 1.0 = 7.0. But RRT* checks all three: xa is slightly farther (1.5) yet has a much lower own-cost (4.5), so routing through it gives 4.5 + 1.5 = 6.0 — cheaper. RRT* parents xnew to xa. The new node's cost-to-come is 6.0, a full unit better than RRT's greedy choice.
The connection radius isn't fixed — it shrinks as the tree grows, like rn = γ(log n / n)1/d, where n is the node count, d the dimension, and γ a constant depending on d (Karaman & Frazzoli 2011). The intuition: when the tree is sparse (small n) you need a big radius to find any neighbors to rewire; as nodes pack in densely, a smaller radius still captures plenty of neighbors, and shrinking it keeps the per-iteration cost bounded. The log n / n form is precisely the rate that guarantees enough rewiring for optimality without the neighbor-checks ballooning.
Watch RRT and RRT* race on the same problem. The left tree is plain RRT — jagged, frozen edges. The right is RRT* — you can see the rewiring straighten its edges over time, and its path (orange) hugs the straight-line ideal far more tightly. Both reach the goal; only RRT* keeps improving.
Press grow both. Left = plain RRT (edges freeze the instant they're added). Right = RRT* (re-parents neighbors as cheaper routes appear). The path-cost readouts diverge: RRT* converges toward the dotted straight-line ideal; RRT stays stuck with whatever it first found.
python def rrt_star_insert(T, x_new, r): near_ids = T.within_radius(x_new, r) # neighbors within r # --- choose-parent: pick the lowest-cost collision-free parent --- best, best_cost = None, np.inf for i in near_ids: if segment_free(T.nodes[i], x_new): c = T.cost[i] + np.linalg.norm(x_new - T.nodes[i]) if c < best_cost: best, best_cost = i, c j = T.add_node(x_new, parent=best, cost=best_cost) # --- rewire: re-parent neighbors that x_new now reaches more cheaply --- for i in near_ids: thru = best_cost + np.linalg.norm(T.nodes[i] - x_new) if thru < T.cost[i] and segment_free(x_new, T.nodes[i]): T.set_parent(i, j); T.cost[i] = thru # rewired! return T
Sometimes you don't need the global optimum — you just need to clean up RRT's jagged output cheaply. And sometimes the path itself isn't enough: a real wheeled robot can't follow sharp corners. This chapter covers both fixes: shortcutting (post-processing the path) and kinodynamic planning (building dynamically-feasible edges in the first place).
The simplest path cleanup is the Shortcut algorithm (HW2 Algorithm 3). The idea is one line: if you can connect a node's parent directly to its child with a collision-free straight line, delete the node in between. Repeat until no more deletions are possible.
Say RRT returned the path A=(0,0) → B=(2,3) → C=(4,0) — a peak that detours up to B. Total length:
Shortcut asks: is the segment A→C (skipping B) collision-free? If yes, delete B. The new straight path A→C has length:
We cut the path from 7.22 to 4.00 — a 45% reduction — by removing one needless corner. That's the whole algorithm.
Try it: grow an RRT, then press shortcut and watch corners vanish as the path tightens against the obstacles.
Press find for a raw RRT path (orange, jagged). Then shortcut: each interior node whose parent→child line is collision-free gets deleted, snapping the path straighter. The length readout drops with each pass.
Geometric RRT connects nodes with straight lines. But recall Lesson 2: a unicycle / differential-drive robot cannot move sideways — the no-slip constraint forbids it. A geometric path with a 90° corner demands the robot stop, spin in place, and restart. That's why HW2 notes geometric paths "are not ideal candidates to track… sharp corners would require stopping and turning at most nodes."
Kinodynamic motion planning bakes the robot's motion constraints into the tree. The state isn't just position — it includes orientation and possibly velocity — and the dynamics are governed by ẋ = f(x, u), read "the rate of change of the state equals some function of the current state x and the control input u." Two ways to grow constraint-respecting edges (Lecture 6):
Everything comes together here. This is a full RRT / RRT* planner in a 2D obstacle field. You set the start, goal, and obstacles; press Grow; and watch the tree expand node-by-node toward the goal, then report the path it found. Toggle RRT vs. RRT* to see rewiring straighten the path, and tune the step size η and goal-bias p live.
Click empty space to drop a circular obstacle. Grow expands the tree; when it reaches the goal it draws the path (orange) and reports node count + path cost. Flip RRT ↔ RRT* and watch RRT* rewire to a straighter, cheaper path. Drag η (stride) and p (goal bias). The dotted grey line is the straight-line ideal.
Things to try, each demonstrating a chapter:
sample, nearest, steer, collision-check, add loop — plus goal biasing and RRT*'s choose-parent / rewire — is exactly what the Stanford assignment asks you to fill into RRT.solve, find_nearest, and steer_towards. The only thing this playground hides is the high-dimensional case, where the very same code plans for a 6-DOF arm — the curse from Chapter 0, defeated.Notice what's not here: a grid. There is no discretization anywhere in the showcase — the tree probes a continuous space directly. That is the entire point of sampling-based planning, and why the same five-line loop scales from this 2D toy to a robot arm in a cluttered kitchen.
You now own two whole families of planners: search-based (Lesson 3's grid A*) and sampling-based (this lesson's PRM / RRT / RRT*). They are not rivals so much as tools for different jobs. Picking the wrong one makes a problem hard that the other makes easy — so let's settle when each wins.
| Question | Grid A* (search) | RRT / RRT* (sampling) |
|---|---|---|
| Dimensionality | Low (2–3D) — cells = resdim explodes beyond that | High (6D, 12D, …) — cost independent of dimension |
| Optimality | Optimal at grid resolution (with admissible heuristic) | RRT: poor. RRT*: asymptotically optimal |
| Completeness | Resolution-complete; can prove no path exists at that resolution | Probabilistically complete; can't prove no path |
| Anytime? | No — returns when search finishes | Yes — usable path early, refines with time (esp. RRT*) |
| Changing environment | Re-run search on the new grid | RRT is single-query, ideal; PRM must rebuild |
| Motion constraints | Awkward to bake in | Natural via kinodynamic edges (Ch 7) |
Concretely:
And don't forget the start of the lesson: deterministic sampling (low-dispersion sequences) can match the asymptotic optimality of random sampling while being reproducible and certifiable — valuable for safety-critical robots where "the planner did something different this run" is unacceptable.
You can now plan motions in spaces too big to grid. On the autonomy stack, this lesson lives in the Motion Planning module — it consumes a map + goal and produces a collision-free path. Here is where it sits among its neighbors.
| Lesson | Role on the stack | How it connects here |
|---|---|---|
| 3 — C-space & A* | Planning (search) | The grid + A* this lesson replaces in high-D; still optimal in low-D |
| 4 — Sampling & RRT (here) | Planning (sampling) | PRM, RRT, RRT* — probe the C-space instead of gridding it |
| 5 — Trajectory optimization | Planning → Control | Turns the jagged path into a smooth, time-parameterized, trackable trajectory |
| 2 — Kinematics / SE(2) | Control | The no-slip unicycle model behind kinodynamic RRT and Dubins steering |
is_free) · connect free configs by collision-free local paths.