Learning To Drive
I watched the virtual car in a synthetic town rendered by a video game engine follow a predetermined path to reach its destination. Then I watched it again, and again and again. I wrote an eval script which counted exactly 6 successes out of 10. My unsophisticated, RL based model has finally learned to drive!
This is a small thing by the standards of the field. A near trivial thing, a simulated car with perfect sensors, 200 meter route, no traffic, no pedestrians. But between that first baseline model which was stalled in one place to the quiet Tuesday night I saw my simulated car finally touch the finish line, I had a lot of learnings that I would like to share in this post.
Why I Started Teaching a Car to Drive
I live in the heart of The Silicon Valley in California. Not a day goes by without seeing those autonomous driverless Waymo driving themselves around. They have always fascinated me. I am an ML engineer by profession and I love to tweak things. I started this journey to learn how these self driving algorithms work under the hood - and pick up some reinforcement learning skills on the way.
CARLA
CARLA is an open-source driving simulator built on Unreal Engine. It gives you a city, a car, sensors, physics, and a Python API that exposes everything. It’s a real self-driving testbed that a person can run at home, in a Docker container, on a single machine.
The CARLA bundles itself with several examples. Before I start building my own agent, I tried a few examples to get a hang of running the simulator and making sure everything works correctly. The simultor setup itself took some time. At the time of writing this, the official carla isn’t supported on Ubuntu 26.04. So I downloaded a docker image and ran the server with GPU support.
docker pull carlasim/carla:0.9.16
docker run --privileged --gpus all --net=host -v /tmp/.X11-unix:/tmp/.X11-unix:rw carlasim/carla:0.9.16 /bin/bash ./CarlaUE4.sh -vulkan -RenderOffScreen
Then I cloned the CARLA repo and tried manual_contro.py spending a few minutes driving around. It also contains automatic_control.py which has some hardcoded rules to allow the car drive around the city.
My First RL Driving Agent
Simple setup:
- CARLA server (Docker, Unreal Engine) running a town and ticking in synchronous mode at a fixed 20 Hz.
CarlaGymEnv— agymnasium.Envwrapper. It spawns a vehicle at a random spawn point, picks a goal 100–200 m away, and uses CARLA’sGlobalRoutePlannerto resolve an actual point-A-to-point-B path along the road graph, sampled into waypoints every 2 meters.- PPO from Stable-Baselines3 (
MlpPolicy), training viatrain.py, inspected viadrive.py(a pygame window showing the camera feed) and scored viaeval.py.
The observation was four numbers:
[speed, distance to next waypoint, angle to next waypoint, distance to goal]
The action was two numbers: steering in [-1, 1] and throttle in [0, 1].
There was no brake - the agent had no way to slow down.
The reward was crude: a flat +10 per waypoint passed, and terminal penalties
for crashing. PPO with the standard recipe: n_steps=2048, n_epochs=10,
gamma=0.99, and ent_coef=0.0 — the Stable-Baselines3 default
The first result: “baseline behavior only.” The car existed, moved, mostly wandered and crashed. Then round 2 was a disaster — and not the fun kind. Reward hovered around −9.98, episodes lasted 1–4 steps, and I burned ~2,700 episodes before I stopped it. The root cause had nothing to do with reward design: the training process was executing a stale, buggy version of the stall-detection code that was still loaded in memory from an earlier session. I had been tuning a reward against an environment that wasn’t the environment I thought I was running.
Lesson one, learned the hard way: before you blame the reward, verify the execution.
3. The Car Learned to Stand Still
Rounds 3 through 7 were steady progress. I widened the observation to six
dimensions, then eight. I added exact cause-of-death logging
(crash / off_road / wrong_way / stall / timeout / success), a
lane-invasion sensor, off-road detection, wrong-way detection, input
normalization, and a held-out deterministic evaluation. By round 7 the car
stayed in its lane and followed curves — something it had never done before.
But it still never reached the destination.
Round 8 exposed the first real pathology: reward hacking. Because the reward
included a flat per-tick term proportional to speed, the policy discovered it
could net enormous reward just by driving fast and surviving long — including on
episodes that ended in a crash. One episode logged reward=4732.67,
reason=crash. The agent was optimizing “stay alive and collect speed reward,”
not “finish the route.” I removed the speed reward.
Round 9, the car learned to stand still.
I had stripped the flat speed term, stacked on penalties (steering smoothness,
lane offset, obstacle proximity), and cranked up the terminal punishments. The
result: a policy that collapsed onto the lowest-risk behavior available —
barely moving, or creeping forward a few meters while sawing the wheel back and
forth in place. The episode log’s back half was a wall of reason=stall.
Two compounding causes. First, ent_coef=0.0 meant no exploration pressure at
all: once the policy found a low-risk mode, nothing pushed it back out. Second,
my stall check was gameable — it only fired when speed < 0.1 and
throttle > 0.5, so the policy could sit still while applying gentle throttle
and never trip it.
Rounds 10 and 11 patched both: an entropy bonus, a tighter stall check, unified terminal penalties. The pure freeze went away. In its place: a jittery, oscillating “drunk walk” that was slower and worse than round 8. Every round since round 8 had made driving quality worse, not better.
Then the numbers explained everything. Pulling the reward distribution across rounds:
| Round | Median reward | Best reward | Successes |
|---|---|---|---|
| 8 | +1168 | +5204 | 7 |
| 9 | −217 | −7.5 | 0 |
| 10 | −161 | −14.2 | 0 |
| 11 | −243 | −148.5 | 0 |
Since round 9, not one episode out of 554 earned positive total reward. The reward function had become a pure cost function. And under discounted RL with an everywhere-negative reward, the optimal policy is to end the episode as cheaply and as quickly as possible. Round 11’s single best outcome across 230 episodes was a stall at −148.5. Freezing immediately was literally the argmax.
This is the crucial thing to internalize about reward hacking: the agent isn’t cheating. It is doing exactly what you asked. If you don’t like the behavior, the objective — not the agent — is wrong.
4. Why Reward Shaping Wasn’t Fixing the Problem
At this point I had spent four rounds in the classic whack-a-mole loop: observe bad behavior, tweak a coefficient, rerun 150k steps, eyeball the car, repeat. It was time to admit the loop itself was broken, for two separate reasons.
Reason one: every reward term is an exploit surface. Here is the catalog of the first several failures, each one “fixed” by a new term that created the next exploit:
| Fix | New exploit |
|---|---|
| Flat speed reward | Farmed by driving fast + surviving long (+4,732 on a crash episode) |
| Steering-smoothness penalty | Punished the policy’s own exploration noise ~30× harder than the entropy bonus could counteract → the policy collapsed its action variance, i.e. stopped moving |
| Dashed-lane (Broken marking) penalty | Fired on every legal junction turn — dashed lines are mandatory to cross when turning |
| Obstacle-braking reward | Farmable by parking next to a static prop and holding the brake forever |
| Stall check | Gamed via the throttle band (0.1–0.5) that never tripped it |
Reason two: the methodology couldn’t tell me anything. Every round bundled
two to four changes, ran once (n=1), and was judged by eye. There was no
fixed, reward-independent scoring metric, so “did this round help?” was never
actually measurable above run-to-run noise. I wasn’t running experiments; I was
playing whack-a-mole with my own intuition and calling it training.
The reframe that finally stuck: I was optimizing symptoms. You don’t fix a broken objective by piling more terms onto it — every term enlarges the exploit surface, and every new exploit looks like a new “bug” to patch. The fix had to come from understanding the mechanics underneath (section 5) and from changing the structure rather than the coefficients: rebalance the reward so competent driving is net-positive again, delete the penalty that was fighting the algorithm, raise the discount factor, and move the smoothness fix out of the reward function entirely and into the control layer.
5. What Does PPO Actually Optimize?
I had been using PPO the way you use a library: as a black box that turns reward into behavior. When the behavior went wrong, I blamed the reward. Eventually I stopped guessing and sat down with the actual math. This section is the part of the journey that retroactively explains everything that happened in rounds 9–11.
PPO’s loss, in one line:
loss = −clipped_surrogate(ratio, A) + vf_coef · value_loss − ent_coef · entropy
with ratio = π_new(a|s) / π_old(a|s). The loop is: collect n_steps of
rollout, compute advantages via GAE (“was this action better or worse than
expected from this state?”), then take a few clipped gradient steps. The core
intuition: whatever action got sampled and turned out to have high advantage
gets pushed up in probability; low or negative advantage gets pushed down.
Three details turned out to matter enormously for this project.
Detail one: sigma is not what you think it is. For continuous actions, PPO
outputs a Gaussian per action dimension: a mean μ(s) and a standard
deviation σ. It’s easy to assume σ is another output of the network. It
isn’t. μ(s) is state-dependent, computed by a real forward pass. σ is a
separate, free-standing trainable parameter — log_std, one scalar per action
dimension — that doesn’t depend on the state at all. It starts at exp(0) = 1.0
by default.
Detail two: sigma is pulled in two directions at once. Every update, two
gradient pressures compete on σ. Negative advantages push it down: if large
sampled deviations keep landing on bad outcomes, the policy reduces the
probability of large deviations — which for a Gaussian means shrinking σ.
The entropy bonus pushes it up: Gaussian entropy has a closed form,
H = ½·log(2πe·σ²), monotonically increasing in σ. Whichever pressure is
numerically larger wins.
Detail three: this explains the rounds 9–11 mystery. My steering-smoothness
penalty was computed on the sampled action — and at σ ≈ 1, two consecutive
independent Gaussian samples differ by about 1.13 on average. So the penalty
was largely taxing the policy’s own exploration noise, not genuine erratic
driving. The pressure to shrink σ was roughly 30× stronger than
ent_coef=0.01’s pressure to keep it up. The policy had exactly one way out:
collapse its action variance. Which looks like a car giving up and refusing to
move.
And then there’s gamma. The effective planning horizon of a discounted agent
is 1/(1 − γ) decisions. At γ = 0.99, that’s ~100 decisions — about 5 seconds
at 20 Hz. My +500 success bonus, sitting ~1,000+ steps away at the end of a
route, had a present value of roughly 0.02. Mathematically invisible. The
agent wasn’t ignoring the destination; the destination didn’t exist inside its
objective. I wanted to fix this by jumping to γ = 0.9999, until I thought
through the tradeoff: an extreme gamma makes the value function’s targets much
noisier, and on a ~150k-step training budget, you trade “can’t see the goal”
for “value estimates are too noisy to learn anything.” The moderate route —
raise γ to 0.995 and hold each action for 4 physical ticks — extends the
real-time horizon 4× without gamma’s variance tax.
Lesson: I had been treating PPO’s knobs as tuning parameters. They’re physics. When the behavior is weird, the math explains why — if you’re willing to look.
6. My First Clean RL Experiment
Armed with the diagnosis, round 12 rebuilt the foundation in one shot, with a reasoned change for each identified failure:
- Reward rebalanced back to net-positive for competent driving. Progress shaping up, penalties down. If per-tick reward is positive, ending an episode early already forfeits future reward — a sufficient disincentive against dying — so the huge terminal penalties (which had been actively harmful once per-tick reward went negative) could shrink from 150 to a uniform 30.
-
The steering-smoothness penalty was deleted entirely and replaced with two structural fixes at the control layer: action-repeat (each decision held for 4 physical ticks, so the car literally cannot oscillate faster than 5 Hz) and a steer low-pass filter:
applied_steer = 0.7 · previous_applied_steer + 0.3 · raw_sampled_steer gamma0.99 → 0.995, combined with action-repeat, to make route completion visible in the objective.- The dashed-lane penalty reverted; the obstacle-braking reward gated so it
can’t be farmed by parking; a latent learning-rate bug fixed (resumed runs
had been silently ignoring
--learning-rate).
And then, critically, round 13 was a pure continuation: git diff against
HEAD was empty. No code, no hyperparameter change. The identical recipe, ~150k
more timesteps. This was the first time my methodology actually isolated a
single variable.
The results are the best thing in this entire project’s history:
- 307 of 1,557 rollout episodes reached the destination — 19.7% (round 12: 43 of 5,296).
- Successes climbed steadily through the run: 35 / 49 / 96 / 127 per quartile, and 68 (34%) in the last 200 episodes. The rate was still rising when the run ended — the checkpoint is not saturated.
- Standalone deterministic evaluation on the final checkpoint: 6/10 routes completed.
Now the discipline part — the caveats I have to repeat to myself every time I get excited:
n = 1. One run. Six of ten attempts on fresh random routes is a ±~15% noise estimate of the true completion rate.- Random routes mean junction-heavy variants are over- and under-sampled unpredictably; I don’t know the difficulty coverage.
- The training data is a long way from solved: 538 crashes, 416 off-road, 298 wrong-way terminations during the round. The degenerate no-move mode is still reachable, just no longer dominant.
But here’s what makes it clean: the improvement is attributable. One thing changed — more steps on a sound recipe — and the success rate did something monotonic and measurable. That only worked because round 12 had built the measurement infrastructure first: exact termination reasons, a deterministic held-out eval, input normalization. You can’t attribute an improvement you can’t measure, and you can’t measure one you didn’t instrument for.
7. Why Observation Design Matters
By round 13, the agent that drives the car perceives the world as a vector of nine numbers:
[speed, distance to next waypoint, heading error to next 5 waypoints (circular mean),
distance to goal, lane offset, heading error vs. road, previous steer, previous throttle,
distance to nearest obstacle ahead]
That vector was built one feature at a time, and each addition was its own lesson in what an agent can and can’t learn from what you give it.
The first lesson was about scale. For the first six rounds, my observation
dimensions spanned wildly different ranges — speed 0–50, distance 0–500, angles
−π..π, actions −1..1 — and I fed them raw to the network. The value loss
literally never decreased. It’s a classic failure: a critic trying to fit a
value function across inputs that differ by orders of magnitude. Wrapping the
environment in VecNormalize (input and reward normalization) was the single
biggest “algorithm-unchanged” improvement I made: explained variance jumped to
~0.67 and episode lengths went from ~100–200 steps to 600–960+.
The second lesson was about features. The policy was weaving on straight roads — and one cause was that a single 2-meter waypoint’s placement jitter could look like a steering-relevant angle change when it wasn’t. Fix: average heading error over the next five waypoints (a circular mean) instead of one. The policy was oscillating and couldn’t stop — partly because it had no idea what it had just commanded. Fix: feed the previous steer and throttle into the observation so the policy can see its own actions. These are tiny changes, and each one changed driving quality more than any reward coefficient I ever tuned.
The third lesson was the sneakiest: a sensor bug masquerades as a policy bug. For weeks, “the car doesn’t turn at intersections” looked like a learned-behavior failure. It wasn’t. My wrong-way detector compared the vehicle’s heading against a lane-id sign convention recorded once at reset — but that sign convention only holds within a single road segment, so turning onto a different road flipped it and falsely flagged correct turns as wrong-way. After one fix, junction connector lanes swung the heading error past 90° during legitimate turns. Both were bugs in the measurement, not the learner — and no amount of reward tuning could have fixed them.
There’s a practical taxonomy buried in these weeks that I wish I’d written down on day one: when an agent misbehaves, which layer is it — observation, reward, model, or algorithm? Most of my early “reward problems” were observation or sensor problems wearing a reward-shaped costume.
8. From State Vectors to Pixels
Status: not built yet. This is the next milestone, not a result.
Here’s the uncomfortable truth about a hand-engineered observation vector: it’s a featurizer written by me. It encodes what I decided a driver needs to know — and it is therefore an upper bound imposed by my imagination. A lane offset, a heading error, an obstacle distance: all are my guesses about which summaries of the world matter.
Pixels are the alternative — hand the raw camera image to the network and let it learn its own features. The plan, when I get there:
- Switch to SB3’s
CnnPolicywith downsampled, normalized camera input. - Use frame stacking so velocity — currently a hand-fed feature — becomes perceivable from pixel motion across frames, the way it is for a human.
- Keep the reward and termination logic byte-identical to round 13’s, so the observation change is the only variable in the experiment.
9. What I Still Don’t Understand About Deep RL
It would be a lie to end this post pretending I’ve got it figured out. I keep a running list of things I use fluently but don’t deeply understand, and it is the honest place to end:
- GAE. I use it, I know the shape of the formula, I could not derive it for you from scratch. That gap sits right at the heart of the algorithm that eventually worked.
- SAC. PPO got the car driving, but PPO is on-policy, discards its rollouts, and needed a fixed entropy coefficient babysitting it the whole way. Would SAC — off-policy, replay-buffered, with self-tuned entropy — have avoided rounds 9–11 entirely? Or introduced its own pathologies? The comparison experiment is on the list.
- Jitterness. See the jittery drive in the video at the top of the post? I have not been able to fix that. I used action low pass filter and action repeat to limited success. Not sure how far to push those two dials.
And one personal note that belongs here. Mid-project, I wrote in my thinking log: “It probably isn’t getting enough reward for completing. Maybe I should increase it to 10000.” It took an honest critique from my research partner (Claude Code) to stop me: don’t pull a number out of feeling — near the goal, a 10,000 bonus destabilizes gradients, and the right reward scale is something you derive from the discount factor, not something you pick. I was about to tune the objective by vibes, at the exact moment the whole project had taught me not to.
That’s the loop, and it’s why I’m writing this down in public. The car drives now — some of the time, on simple routes, in a simulated town. That’s a starting line, not a finish. And the reader is now, as I was a few months ago, the narrator of round 14.