Principles of Robot Autonomy I · Lesson 4 of 15 · Stanford AA274A

Throwing Darts at the Configuration Space: Sampling-Based Planning & RRT

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.

Prerequisites: Lesson 3 (C-space & A*) + a little Python + distance = √(Δx²+Δy²).
11
Chapters
6
Simulations
0
Assumed Knowledge

Chapter 0: The Grid That Ate Your Computer

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.

The one-sentence problem. Grid-based planning (Lesson 3's A*) works beautifully in 2–3 dimensions but dies in high dimensions: the cell count is resolutiondimension, so a 6-joint arm at coarse 100-bucket resolution already needs a trillion cells. You cannot grid what you cannot fit in memory.

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.

The Curse of Dimensionality — cells explode with dimension

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.

Buckets / axis 20 A 6-DOF arm is the wall.

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.

The escape route. Stanford's Lecture 6 frames it precisely: combinatorial / grid methods try to completely characterize the free space Cfree before planning. Sampling-based methods abandon that goal. Instead of describing the whole space, they probe it — throw down random configurations, ask a collision checker "is this one free?", and connect the survivors. You never build the grid. You sample your way to a path.

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.

Why does grid-based A* (Lesson 3) become intractable for a 6-DOF robot arm?

Chapter 1: Don't Map the Space — Probe It

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:

The key reframe (Lecture 6). Sampling-based planners "transform the difficult global problem into a large set of local and easy problems." The global question "what is the shape of all free space?" is intractable. The local question "is this one point free?" and "is this one short segment free?" is trivial — and you answer it only where you actually look.

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.

The Sampler — throw darts, keep the free ones

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.

0 free / 0 sampled

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.

Common confusion. "Sampling-based" does not mean "samples on a fixed grid." Lesson 3's A* sampled a deterministic grid; that is still combinatorial in spirit. Here the samples are drawn incrementally and at random, with no pre-laid structure — the planner decides where to look based on what it has already found, never enumerating the whole space.
What role does the collision checker (is_free) play in a sampling-based planner?

Chapter 2: Probabilistic Roadmaps — Build Once, Query Many

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.

The algorithm, line by line

Stanford's Lecture 6 gives the preprocessing step. Every symbol is defined as it appears:

Why a connection radius r, not connect-everything? If you tried to connect every pair of vertices you'd have n²/2 candidate edges, each needing an expensive collision check, and a dense graph that's slow to search. The radius r keeps the graph sparse: only nearby vertices are candidates. Too large and you waste collision checks; too small and the roadmap fragments into disconnected islands and the query fails even though a path exists.

Worked example: edge validation by hand

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?

‖B − A‖ = √((4−1)² + (5−1)²) = √(9 + 16) = √25 = 5

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):

tpoint on segmentis_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.

Build a Probabilistic 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.

radius r 0.32
Sample vertices to begin.

PRM in code

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.

Common confusion. PRM's payoff is the multi-query setting: a warehouse robot fetching from 50 different shelves in the same static layout builds the roadmap once and reuses it 50 times. But if the environment changes — a box gets moved — the roadmap may now route through the box, and you must rebuild it from scratch. For single-query, changing environments, building a whole roadmap is wasted effort. That is exactly the gap RRT fills next.
When is PRM's "build once, reuse many times" strategy worth the up-front cost?

Chapter 3: Rapidly-exploring Random Trees — Grow a Tree From the Start

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:

The steer step is what bounds growth. By stepping only distance η toward the random sample, each new edge is short and easy to collision-check, and the tree advances steadily rather than leaping across the space. η is the planner's stride length: too big and edges punch through thin obstacles; too small and the tree crawls and burns iterations.

Worked example: one extension, with actual numbers

Let's run a single RRT iteration by hand. Suppose the tree currently has three nodes:

x0 = (1.0, 1.0)  (root)    x1 = (2.0, 1.5)    x2 = (1.5, 3.0)

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:

d(x0) = √((5−1)²+(4−1)²) = √(16+9) = √25 = 5.00
d(x1) = √((5−2)²+(4−1.5)²) = √(9+6.25) = √15.25 ≈ 3.91
d(x2) = √((5−1.5)²+(4−3)²) = √(12.25+1) = √13.25 ≈ 3.64

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:

û = (3.5, 1.0) / 3.64 ≈ (0.962, 0.275)

Step distance η = 1.0 along that direction, added to xnear:

xnew = xnear + η · û = (1.5, 3.0) + 1.0·(0.962, 0.275) = (2.462, 3.275)

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.

Sanity check the arithmetic. The new node sits distance √((2.462−1.5)²+(3.275−3)²) = √(0.925+0.076) = √1.001 ≈ 1.00 from xnear — exactly η, as it must be. We stepped toward the sample but landed only one stride away from the tree.

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.

RRT, one extension at a time

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.

step η 0.08
1 node. Press step.

Why does the tree rush into empty space? The Voronoi bias

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.

Common confusion. RRT does not step toward the goal each iteration — it steps toward a random sample. That's what makes it explore the whole space instead of charging blindly at the goal and getting stuck behind the first obstacle. We do nudge it toward the goal occasionally, but only with a small probability — that's goal biasing, the next chapter.
Why does an RRT preferentially expand into unexplored, open regions of the space?

Chapter 4: Goal Biasing — A Gentle Pull Toward the Target

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:

sample z ∼ Uniform(0,1)
a coin flip in [0,1]
if z < p
xrand = xgoal (aim at goal)
else
xrand = random
explore as usual
Why not just always aim at the goal (p = 1)? Because then RRT collapses into a greedy hill-climber: it marches straight at the goal and jams against the first obstacle in the way, with no exploration to find a way around. The Voronoi bias that lets RRT escape local traps comes from the random samples. Goal biasing is a seasoning, not the main ingredient — typical p is 0.05 to 0.10.

Worked example: how often do we aim at the goal?

With p = 0.05 and the coin flips z = [0.71, 0.03, 0.88, 0.42, 0.01] over five iterations:

iterzz < 0.05?xrand is…
10.71norandom sample
20.03yesthe goal
30.88norandom sample
40.42norandom sample
50.01yesthe 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.

When do we stop?

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.

Goal-biased RRT — tune the pull

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.

goal bias p 0.08
p=0.08. Press grow.
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
Common confusion. Goal biasing is not a heuristic like A*'s distance-to-goal estimate. A* uses the heuristic on every node expansion to order its search. RRT can't — it grows from whatever node is nearest a random sample, with no global cost-ordering. Goal biasing is the cruder, cheaper substitute: just occasionally pretend the random sample is the goal. That's why HW2 says "we cannot add the same heuristic as A*… instead, we use a goal-biasing approach."
In goal-biased RRT, what happens if you set the goal-bias probability p too high (near 1)?

Chapter 5: What RRT Promises — and What It Doesn't

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.

Probabilistic completeness

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:

Definition (Lecture 6). RRT is probabilistically complete: if a solution exists, the probability that RRT finds it approaches 1 as the number of samples n → ∞. (LaValle 1998; Kleinbort et al. 2018, assuming a bounded C-space.) In words: keep sampling long enough and you'll almost surely find a path — but there's no finite n that guarantees it, and if no path exists, RRT can only ever say "still searching," never "no path."

Compare the three guarantee levels you now know:

PlannerGuaranteeCatch
Grid A* (Lesson 3)Resolution-completefinds a path if one exists at that grid resolution; dies in high-D
RRTProbabilistically completeP(found) → 1 as n → ∞; can't prove no-path
RRT* (next chapter)Asymptotically optimalpath cost → optimal as n → ∞; even slower per node

RRT says nothing about optimality

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.

RRT's path is jagged — and stays jagged

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.

Press find path.
Common confusion. "Probabilistically complete" is not "eventually optimal." It only promises you'll find a path (any path), not a good one. Optimality is a separate, stronger property that vanilla RRT does not have — you need RRT* for that. Mixing these up is the single most common RRT misconception.

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 is probabilistically complete" means which of the following?

Chapter 6: RRT* — Rewiring Toward the Optimum

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:

The rewiring is the whole trick. Vanilla RRT freezes every edge forever. RRT* re-examines the neighborhood of each new node and re-parents any node that the new node can reach more cheaply. Over many iterations this continuously straightens and shortens the tree — locally correcting the greedy mistakes RRT could never undo. That local label-correction is what earns asymptotic optimality.

Worked example: choose-parent by hand

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 parentpositionc (cost-to-come)‖xnew−parent‖c + edge
xnear(3.0, 4.0)6.01.007.00
xa(4.0, 2.5)4.51.506.00
xb(2.5, 3.0)5.01.806.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 shrinking radius

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.

RRT vs. RRT* — watch the rewiring straighten the path

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.

Press grow both.
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
Common confusion. RRT* is not "RRT that finds the optimum." It's asymptotically optimal — the path keeps shrinking toward the optimum as you keep adding samples, never quite arriving in finite time. Stop it early and you get a good-but-not-perfect path. It's also slower per node than RRT (extra neighbor checks and rewiring), so RRT* trades CPU for path quality. It's an anytime planner: it has a usable path quickly and refines it the longer you let it run.
What makes RRT* asymptotically optimal while plain RRT is not?

Chapter 7: Path Smoothing & Kinodynamic RRT

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).

Shortcutting: snip the corners

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.

for each interior node x
(not start, not goal)
if segment(parent, child) free
the corner at x is unneeded
remove x
parent now joins child directly
↻ until no removals

Worked example: one shortcut

Say RRT returned the path A=(0,0) → B=(2,3) → C=(4,0) — a peak that detours up to B. Total length:

‖B−A‖ + ‖C−B‖ = √(4+9) + √(4+9) = √13 + √13 ≈ 3.61 + 3.61 = 7.22

Shortcut asks: is the segment A→C (skipping B) collision-free? If yes, delete B. The new straight path A→C has length:

‖C−A‖ = √((4−0)²+0²) = 4.00

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.

Shortcut smoothing — delete the needless corners

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.

Press find.
Shortcutting is a local optimization. HW2 is blunt about its limit: shortcutting "is not likely to move the path to the other side of an obstacle." It tightens within the route's homotopy class (the family of paths you can deform into each other without crossing an obstacle) but won't discover a fundamentally different, shorter route on the far side of a wall. For that global search you need an asymptotically-optimal planner like RRT*.

Kinodynamic RRT: edges the robot can actually drive

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):

Why a Dubins car instead of the full dynamics? HW2 explains: solving the general 2-point boundary-value problem (the steering function) is expensive, and adding orientation + velocity to the state "triples the problem dimension." The Dubins car is a clever compromise — fixing the speed leaves only the turning, for which a closed-form shortest path exists. The full no-slip steering connects back to Lesson 2's kinematics, and the trajectory-tracking that follows is Lesson 5's job.
Common confusion. "Smoothing" and "kinodynamic planning" solve different problems. Smoothing shortens an already-found geometric path; it doesn't make the path dynamically feasible — a smoothed path can still have a corner the robot can't turn. Kinodynamic planning makes every edge feasible during the search, so there are no infeasible corners to fix. In practice (HW2 Problem 3) you often combine a geometric plan with a spline-fit + a tracking controller rather than full kinodynamic RRT, because the latter is slow.
Why can't geometric RRT's straight-line path always be driven directly by a wheeled (unicycle) robot?

Chapter 8: The RRT Playground — Grow a Tree to the Goal

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.

Live RRT / RRT* — the full algorithm in a 2D field

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.

step η 0.060 goal bias p 0.08
RRT · nodes: 1 · press Grow

Things to try, each demonstrating a chapter:

You just built the heart of HW2 Problem 2. The 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.

Chapter 9: A* or RRT*? — When to Search, When to Sample

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.

QuestionGrid A* (search)RRT / RRT* (sampling)
DimensionalityLow (2–3D) — cells = resdim explodes beyond thatHigh (6D, 12D, …) — cost independent of dimension
OptimalityOptimal at grid resolution (with admissible heuristic)RRT: poor. RRT*: asymptotically optimal
CompletenessResolution-complete; can prove no path exists at that resolutionProbabilistically complete; can't prove no path
Anytime?No — returns when search finishesYes — usable path early, refines with time (esp. RRT*)
Changing environmentRe-run search on the new gridRRT is single-query, ideal; PRM must rebuild
Motion constraintsAwkward to bake inNatural via kinodynamic edges (Ch 7)
The one-line decision rule. Low-dimensional and you need the provably-optimal path? A*. High-dimensional, or you have an anytime/online budget, or you have differential constraints? RRT*. Static environment with many queries and modest dimension? PRM. Most real robot stacks use both: a coarse A* for the global route, a sampling planner for the high-DOF local maneuver.

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 must plan a collision-free motion for a 7-DOF robot arm into a cluttered cabinet, with an anytime budget (use the best path you have when time runs out). Which planner family fits best?

Chapter 10: Connections & Cheat-Sheet

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.

LessonRole on the stackHow 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 optimizationPlanning → ControlTurns the jagged path into a smooth, time-parameterized, trackable trajectory
2 — Kinematics / SE(2)ControlThe no-slip unicycle model behind kinodynamic RRT and Dubins steering

Cheat-sheet

The problem: grid A* cells = resolutiondimension — explodes for high-DOF robots (the curse of dimensionality). Sample instead of grid.

The three primitives: sample a random config · collision-check it (black-box is_free) · connect free configs by collision-free local paths.

PRM: sample n free vertices, connect pairs within radius r whose segment is free, query by graph search. Multi-query; rebuild if the world changes.

RRT: grow a tree from start. Each iter: sample → nearest xnear → steer step η to xnew → collision-check → add. Voronoi bias drives exploration. Single-query.

Goal biasing: with prob p (~0.05–0.1) sample the goal instead of randomly. Too high → greedy & trapped.

Guarantees: RRT is probabilistically complete (P(found)→1 as n→∞) but produces jagged, suboptimal paths and can't prove no-path.

RRT*: choose-parent (lowest cost-to-come within r) + rewire neighbors; shrinking radius rn ∝ (log n / n)1/d. Asymptotically optimal, anytime.

Cleanup: shortcutting deletes needless corners (local, stays in homotopy class). Kinodynamic RRT bakes ẋ=f(x,u) into edges (Dubins / Reeds-Shepp steering) so the path is drivable.

Where to go next on Engineermaxxing

"What I cannot create, I do not understand." — Richard Feynman.
You can now create a planner that finds paths in spaces too vast to draw. Next, Lesson 5: making that path smooth and trackable — turning a sequence of waypoints into a trajectory your robot can actually follow.
On the autonomy stack, what does this lesson's RRT planner produce as its output, which Lesson 5 then refines?