An autonomous line-maze solving robot built on an Arduino Nano. It explores an unknown maze using the left-hand rule, records every decision it makes, collapses dead-end excursions into shortcuts as it goes, and saves the solved path to EEPROM so it survives a power cycle.
Version 4.2.4.2 · Full case study →
Two layers, and keeping them separate is the whole design.
The inner loop is continuous control. A 5-channel IR array reads the line, a weighted average turns those five readings into a single position error, and a PID controller converts that error into a differential correction — added to one wheel, subtracted from the other.
The outer layer is discrete. Every cycle, loop() classifies what the array is looking at.
If it sees something other than plain line, it interrupts PID and runs a manoeuvre instead:
readSensor()
├── finishDetected() → record 'F', save to EEPROM, halt
├── junctionDetected() → handleJunction() (Left > Straight > Right)
├── deadEndDetected() → record 'U', turnBack()
└── else → followLine() (PID)
Checks run in that order deliberately: finish first because stopping is irreversible, then junctions, then dead ends.
This is the part worth reading the code for.
Every dead-end excursion leaves a three-move signature in the recorded path: the turn taken into
the branch (X), the U-turn at the dead end (U), and the turn taken on the way back out (Y).
A robot that already knew the maze would have made a single move instead of those three.
Most implementations hardcode a substitution table — LUL→S, LUR→U, SUS→U, and so on.
reducePath() derives all of them from one formula instead, by treating each move as a rotation:
| Move | Angle |
|---|---|
S straight |
0° |
L left |
90° |
U U-turn |
180° |
R right |
270° |
combined = (angle(X) + 180 + angle(Y)) mod 360
LUL → 90 + 180 + 90 = 360 ≡ 0° → S. Turned left, hit a dead end, turned left again — the net
effect is driving straight through that junction, so that's what gets stored.
Reduction runs live, on every recorded move, not as a post-processing pass. And because
collapsing one triple can leave a new U adjacent to another pair, reducePath() loops until no
triples remain: R U (S U S) → R U U → S.
The result is that by the time the robot reaches the finish, the path in memory is already the solved shortest route — no separate solving step needed.
| Subsystem | Part |
|---|---|
| Controller | Arduino Nano (ATmega328P) |
| Line sensor | 5-channel IR reflectance array |
| Motor driver | TB6612FNG dual H-bridge |
| Motors | 2 × N20 micro metal gear motor |
| Input | Normally-open pushbutton (replay trigger) |
| Signal | Pin | Notes |
|---|---|---|
S1–S5 |
A0–A4 |
IR array, S1 = leftmost |
PWMA |
D5 |
Right motor speed |
AIN1 / AIN2 |
D3 / D4 |
Right motor direction |
PWMB |
D9 |
Left motor speed |
BIN1 / BIN2 |
D7 / D8 |
Left motor direction |
replayButton |
D10 |
INPUT_PULLUP — pressed reads LOW |
LED_BUILTIN |
D13 |
Lit while a junction is being handled |
Note the A/B mapping: driver channel A drives the right motor, channel B the left.
Sensor polarity is 0 = black, 1 = white — analogRead() below threshold counts as black.
All live at the top of hammerHead.ino and followLine.ino.
| Parameter | Value | What it does |
|---|---|---|
Kp |
50 |
Proportional gain |
Ki |
0.0 |
Integral gain — unused; error is never meant to be steady on a maze |
Kd |
5 |
Derivative damping |
threshold |
500 |
Analog cutoff between black and white |
baseSpeed |
150 |
Cruise PWM |
maxSpeed |
200 |
PID output clamp |
ovsDelay |
360 ms |
Aligns the rotation centre with the junction before pivoting |
ignoreJunction |
120 ms |
Blanking window after a turn, stops re-detecting the junction just left |
maxPath |
100 |
Maximum recorded moves |
Speed calibration: speed (mm/s) = 1.12 × PWM − 14.10
- Set
Ki = Kd = 0, raiseKpuntil it tracks but oscillates. - Add
Kduntil the oscillation damps out. - Leave
Kiat 0 unless there's a persistent drift to one side. - Raise
baseSpeedand repeat — gains that hold at 150 may not hold at 200.
Position error. Sensors are weighted S1 = −2, S2 = −1, S3 = 0, S4 = +1, S5 = +2 and divided
by the number of channels seeing black, giving a continuous error even when the line falls between
two sensors.
Line lost. If no sensor sees black, the robot spins in place toward the side it last drifted
from — previousError decides the search direction rather than guessing.
Junction. Two or more of {left, straight, right} open at once. handleJunction() applies a
stricter test than the initial detection: a real side branch is a perpendicular crossbar, so it
must cover both sensors on that side (s1 && s2, or s4 && s5). A skewed pass over a straight
line can put a single outer sensor on the line and look like a branch — requiring the pair rejects
that, so false junctions resolve to "straight only" and record nothing.
Dead end. All five white, confirmed by nudging forward 200 ms and re-reading.
Finish vs. junction. Both read all-black the instant the array crosses them, so they can only
be told apart by what happens next. finishDetected() creeps forward at half speed for up to
500 ms, sampling every 25 ms. A junction crossbar is narrow — the array exits black almost
immediately. A finish box is wide enough that the robot is still inside it when the timer expires.
Only real decisions get recorded. A corner with one available option is followed but not written to the path — it isn't a choice, and recording it would corrupt the reduction arithmetic.
rampTo(targetLeft, targetRight, duration) steps from the current commanded speed to the target in
10 ms increments rather than jumping. Every junction manoeuvre uses it. A hard step from forward to
full reverse produces a torque spike that makes the wheels slip and throws off the turn geometry —
ramping costs a few tens of milliseconds and removes the problem.
hammerHead/
├── hammerHead.ino Pin definitions, globals, setup(), loop()
├── sensing.ino readSensor() and all detection predicates
├── followLine.ino PID controller
├── junctionHandling.ino handleJunction(), turnLeft/Right/Back, spinUntilLine()
├── motorControl.ino motorControl(), stopMotors(), rampTo()
├── savingPath.ino recordMove(), reducePath(), EEPROM persistence
└── callGraph_V4.2.4.2.svg Function call graph
Arduino concatenates .ino files with the main sketch first, which is why path[], pathLength
and maxPath are declared in hammerHead.ino — unlike functions, globals get no auto-generated
forward declarations.
Requirements: Arduino IDE (or arduino-cli), EEPROM library (bundled with the AVR core).
- Clone this repo.
- Open
hammerHead/hammerHead.ino— the IDE picks up the other.inofiles automatically. - Select Board: Arduino Nano, and the right Processor (ATmega328P, or Old Bootloader depending on your clone).
- Upload.
- Open Serial Monitor at 115200 baud.
On startup the robot prints any path stored in EEPROM from the previous run, then clears its working copy and begins a fresh exploration. EEPROM is only overwritten when a new finish is reached.
Working: exploration, left-hand rule navigation, live path reduction, EEPROM persistence.
Not yet implemented: the replay run. The pushbutton on D10 is wired and read
(isReplayButtonPressed()), but nothing acts on it. The plan is to drive normally via
followLine() and, at each junction, take the next move from the solved path instead of applying
the left-hand rule.
- Wheel encoders — turn and confirm-forward distances currently depend on timed delays, which drift as the battery discharges.
- Serial telemetry of position error, for plotting instead of tuning by eye.
- Separate 5 V regulator for logic, so the current spike from a U-turn can't brown out the Nano.
MIT