Jerry the Micromouse: Building a Maze-Solving Robot from Scratch

October 21, 2026

Jerry is a small autonomous robot. Placed in a maze it has never seen before, it finds its way to the center on its own and then races there as fast as it can. Over one semester at TUM, our team of four designed, built and programmed Jerry from scratch: from the schematic and the PCB, over a 3D-printed chassis, to the firmware that explores and solves the maze.

This post summarizes the project: the main design decisions, the problems we ran into and what we learned from them.

Jerry, a small two-wheeled micromouse robot with a green PCB on top of a black 3D-printed casing
Jerry, our finished micromouse.

The challenge

In the official competition, a micromouse has to solve a 16 × 16 maze. It starts in a dead end in one corner and has to reach the 2 × 2 goal area in the center. The robot knows the dimensions of the maze, but not its layout. A run therefore has three phases: explore the maze, return to the start, and finally race to the center on the shortest path.

For our lab, we used a smaller 6 × 6 maze. It is smaller, but poses the same fundamental problems: detect walls reliably, always know where you are, and never touch a wall while doing so.

Top view of a 16 by 16 competition maze next to a 6 by 6 lab maze
The full 16 × 16 competition maze (left) vs. our 6 × 6 lab maze (right).

We defined the following goals:

  • Sensing: reliable wall detection with calibrated infrared sensors and precise wheel odometry from the motor encoders
  • Motion: closed-loop control to drive centered between the walls, plus in-place 90° and 180° turns
  • Mapping: a memory-efficient maze representation on the microcontroller, autonomous exploration and flood-fill path planning
  • Validation: reliable runs in the physical lab maze, with code that scales to the 16 × 16 format

The building blocks

The microcontroller, the motors and the battery were given by the course. We selected and designed everything else ourselves.

ComponentChoiceNotes
MicrocontrollerdsPIC33FJ64MC80416-bit DSC with 40 MIPS, 16 KB RAM and dedicated ADC, PWM and encoder peripherals
Motors2× Faulhaber 2619S006SR6 V coreless DC motors with 33:1 gearbox and integrated encoders
Battery2S LiPo (7.4 V)Powers the motors directly and the logic through regulators
Distance sensors3× Sharp GP2Y0A51SK0FAnalog IR sensors for 2–15 cm, fast and compact
Motor driverTB6612FNG dual H-bridgeDrives both motors from one IC, 1.2 A per channel
CommunicationRN4871 Bluetooth LE + UARTWireless debugging while the mouse is driving
DriveDifferential drive + felt glidersTurns in place; no casters needed on the smooth maze floor

A rough estimate confirmed that the motors are strong enough: accelerating a 0.5 kg robot at 1.5 m/s² with 60 mm wheels needs about 11 mNm per wheel, while the geared motors deliver around 30 mNm continuously.

Designing the PCB

We designed the board in KiCad. For the layout, we followed a few simple rules: keep the power supply close to the main consumers (the H-bridge and motors), keep traces short, avoid unnecessary crossings and keep the noisy PWM lines away from the sensitive analog sensor signals.

The microcontroller sits in the center with sensors, communication and peripherals arranged around it. We added an 8 MHz crystal, which the internal PLL multiplies to an 80 MHz oscillator frequency (40 MHz instruction clock), a programming header, a start and a reset button, and connectors for two optional diagonal sensors. For the Bluetooth module, we cut away a corner of the board so that there is no copper below its antenna.

3D render of the final PCB with the microcontroller in the center
3D render of Jerry's final PCB.
PCB layout with highlighted supply, analog and PWM nets and a color legend
Highlighted nets of the final PCB: the analog sensor signals (yellow) are routed away from the PWM signals (pink).

Power: a two-stage cascade

Jerry runs on a single 2S LiPo battery (7.4 V nominal, up to 8.4 V fully charged), but the electronics need 5 V and 3.3 V. Instead of deriving both rails from the battery in parallel, we cascade them:

  • Motors are powered directly from the battery through the H-bridge. Their current spikes, e.g. when changing direction, therefore never reach the logic rails.
  • 5 V for the sensors comes from a synchronous buck converter (AP62300, up to 3 A). A linear regulator would have turned about 2.4 W into heat at 1 A.
  • 3.3 V for the microcontroller, Bluetooth module and encoders comes from a low-dropout regulator (TLV757P) fed by the 5 V rail. It adds no switching noise of its own and suppresses the ripple of the buck converter (46 dB PSRR at 100 kHz). This keeps the supply of the microcontroller and its ADC inputs clean.

Even in the worst case, the 5 V rail only has to supply around 450 mA, well below the 3 A the converter can deliver.

Motors and PWM

The motors are rated for 6 V, the battery delivers 7.4 V. Rather than adding another regulator, we cap the PWM duty cycle at 6 V / 7.4 V ≈ 80 %, which keeps the average motor voltage close to its rating. The PWM runs at 1 kHz.

Oscilloscope showing a 1 kHz PWM signal with 80 percent duty cycle
Oscilloscope check: 1 kHz PWM, capped at 80 % duty cycle even when 100 % is requested.

Putting Jerry on a diet: the casing

Many micromice mount their motors directly onto the PCB. We decided against that to keep mechanical stress off the board and designed a 3D-printed casing instead. It holds the motors, sensors and battery and carries the PCB on top.

The first version started as a simple frame and gradually gained sensor mounts, motor cutouts, mounts for two diagonal sensors and a cutout for the programmer. Originally, the battery was supposed to sit on top. That would have raised the center of gravity and blocked access to the buttons and the programming header, so we flipped the design: battery at the bottom, PCB on top.

Four 3D renders showing the evolution of the first casing
Evolution of the first casing: from a plain frame to the flipped design with the battery mount below.

After the first test runs, we noticed that this casing was too wide for tight corners. So we put Jerry on a diet: the second casing is much narrower, drops the two diagonal sensors in favor of a longer measuring range for the side sensors, and reduces the wheel-to-wheel distance from 130 mm to 103 mm.

3D renders comparing the wide first casing with the narrower second casing from the front and from the top
Old vs. new casing: front view (left) and top view (right).

From parts to a robot

We already learned a few things while ordering the parts:

  • Order parts that are well in stock. A single part with low availability delayed our shipment by a week, which then ran into the holiday season and cost us another two weeks.
  • Order spares. Most components cost only a few cents, so ordering at least one extra of each covers lost or damaged parts at almost no cost.
  • Order alternatives for parts that might hit their thermal or electrical limits. We ordered two footprint-compatible voltage regulators, one with and one without a thermal pad.

We soldered with lead-free solder at about 350 °C, working from the center of the board outwards and from small to large components. Parts with pads underneath, like the crystal oscillator or the regulator's thermal pad, were soldered with a hot air gun. Afterwards, we mounted the sensors in the casing and twisted their power, ground and signal wires to reduce interference.

For the first power-up, we used a lab power supply with a 0.6 A current limit, so that a short circuit could not destroy anything. That worked well until we enabled the PLL: the higher clock rate drew more current than the limit allowed, and Jerry started rebooting seemingly at random until we noticed that the power supply was throttling him.

One more lesson: solder the XT60 battery connector with its counterpart plugged in. We didn't, the nylon inside got too hot, and the pins ended up misaligned.

Four photos: soldering a component, the finished PCB, the PCB with wired sensors and casing, and the board connected to a lab power supply
Soldering the oscillator, the finished PCB, wiring the sensors in the casing, and the first power-up on a current-limited supply.

Firmware

The firmware is written in C and built with MPLAB X and the XC16 compiler. It is split into clearly separated layers: a hardware abstraction layer at the bottom, motion control in the middle and maze solving on top. The maze solver only asks for things like "drive three cells forward" or "turn left" and leaves the execution to the motion controller. This way, the same motion primitives are reused for exploration, the return to the start and the final speed run.

Timers instead of an RTOS

Instead of a real-time operating system, all periodic work is driven by three hardware timers:

TimerPeriodTask
Timer 110 msEdge detection of the start button
Timer 2400 msProcess incoming Bluetooth commands
Timer 310 msMotion control and navigation step

To keep the interrupt routines short, the timers mostly just set a flag and the actual work happens in the main loop. A small helper, initTimerInMS(id, ms), picks the smallest prescaler that fits the requested period into the 16-bit timer.

Sensing walls

Three Sharp infrared sensors look to the left, to the front and to the right. They output an analog voltage that depends on the distance to the wall. The ADC scans all three channels in 12-bit mode, and a DMA channel copies every result straight into memory, so the rest of the code reads the latest value with a simple array access and without any CPU overhead. At roughly 10 µs per full scan, this is far faster than the sensors themselves, which only update every 16.5 ms.

We calibrated each sensor against the actual maze walls. This was more important than we expected, because the sensors deviate noticeably from the datasheet curve and from each other. In early tests, Jerry constantly drifted to the right because we used a single calibration table for all sensors. Individual tables for each sensor fixed the problem.

Chart of sensor output voltage over wall distance for three sensors and the manufacturer curve, next to a photo of the test setup
Measured sensor voltages over the wall distance compared to the manufacturer's curve (left) and the test setup (right).

Encoders and odometry

Both motor encoders are read by the microcontroller's quadrature encoder interfaces in 4× mode. With 16 pulses per motor revolution, 4× decoding and the 33:1 gearbox, one wheel revolution yields 16 · 4 · 33 = 2112 ticks, which is about 11.2 ticks per millimeter with 60 mm wheels. Interpreting the difference between two readings of the 16-bit hardware counter as a signed value correctly handles over- and underflows.

Bluetooth for debugging

A cable works while the robot sits on the desk, but not once it drives through the maze. So we added an RN4871 Bluetooth Low Energy module in transparent UART mode: for the firmware, the wireless link behaves like a serial cable. Every message is wrapped in angle brackets (e.g. <MAP>), which makes it easy to discard incomplete or garbled input.

On the PC side, a small Python console based on the Bleak library connects to Jerry. Over time, the command set grew into a full tuning interface: start the mapping or the speed run, drive or turn on command, print sensor values and encoder ticks, adjust geometry and overshoot parameters at runtime, and print the current maze as ASCII art. Being able to tune parameters without reflashing the firmware saved us a lot of time.

Driving straight: motion control

Driving is based on simple geometry. The maze cells measure 168 mm on the inside plus a 13 mm wall, so driving n cells means driving n · 181 mm worth of encoder ticks. For a turn, each wheel travels along a circle whose diameter is the 103 mm wheel-to-wheel distance.

In practice, the wheels and motors are never perfectly identical, so a controller has to keep Jerry centered between the walls. We use a PD controller on the difference between the measured and the expected side distances. An additional motorTrim term compensates for a constant difference between the two motors and is slowly adapted while driving:

int E = ((dLeftIs - dLeftShould) - (dRightIs - dRightShould)) / 2; int dE = E - lastE; float pTerm = KP * (float)E; float dTerm = KD * (float)dE; int totalCorrection = (int)lroundf(pTerm + dTerm + motorTrim); int speedMotorA = baseSpeed - totalCorrection; int speedMotorB = baseSpeed + totalCorrection; lastE = E;

Tuning the controller

We tuned the controller in the usual way: start with KD = 0 and increase KP until the robot noticeably oscillates between the walls. If KP is too small, Jerry reacts too slowly and clips walls; if it is too large, he oversteers. Then KD is increased to damp the oscillation until the mouse settles smoothly in the center.

Three trajectories of the robot between two walls for different KP values
Finding KP: too low (left), oscillating (middle), oversteering (right).

After a series of full maze runs around the first stable values, we settled on KP = 0.25 and KD = 0.4.

Our biggest mistake was to tune on a straight section only, planning to handle corners later. Values that work well on a straight turned out to fail at junctions and corners, where one or both side walls are missing. It worked much better to tune on a complete test maze, start slow, and increase the speed step by step while re-checking stability.

Turns had a separate problem: they overshot their target angle, and not proportionally to the angle, so a constant correction factor was not enough. Calculating the tick counts from the wheel geometry and adding an overshoot compensation that can be tuned via Bluetooth solved this.

Solving the maze

Keeping a map

Each cell can have a wall on any of its four sides, so 4 bits per cell are enough. Jerry keeps a 2D array of wall bitmasks, a separate grid of visited cells and his current pose:

// bitmask: 1 = N, 2 = E, 4 = S, 8 = W static int walls[MAZE_SIZE][MAZE_SIZE]; static int visit[MAZE_SIZE][MAZE_SIZE]; typedef struct { unsigned int x; unsigned int y; Direction heading; } Pose;

Walls are only measured while Jerry stands still in the center of a cell, where the sensor readings are most reliable. The relative readings (left, front, right) are converted to absolute directions using the current heading. Since every wall belongs to two cells, it is stored in the neighboring cell as well.

Exploring

During exploration, Jerry does not need to be fast. He runs a breadth-first search (BFS) from his current position to find the nearest unvisited cell, treating walls he hasn't seen yet as open. He then drives there cell by cell, recording walls and marking cells as visited. If an unexpected wall shows up in front of him, the planned path is no longer valid, so he discards it and plans again with the updated map. Once every reachable cell has been visited, he plans a path back to the start.

Racing

With the complete map, the classic flood-fill algorithm computes the shortest path: the four goal cells get the distance 0, their reachable neighbors 1, and so on. Jerry then simply follows decreasing values until he reaches 0.

+---+---+---+---+---+---+ | 4 5 | 5 4 3 | 4 | + + + + + + + | 3 | 6 6 | 5 | 2 3 | + +---+---+---+ +---+ | 2 1 0 0 1 2 | + +---+ + +---+ + | 3 4 | 0 0 | 4 3 | +---+ +---+---+ +---+ | 6 5 | 7 6 5 6 | + + +---+---+ +---+ | 7 | 6 7 7 6 7 | +---+---+---+---+---+---+

Stopping and starting again at every cell wastes a lot of time. So before driving, consecutive cells in a straight line are merged into a single "drive n cells forward" command. This made the cell traversal 41.6 % faster and saved 0.26 s per cell on a five-cell straight.

Results

We tested Jerry in a number of different maze configurations. In every one of them, he explored and mapped all reachable cells correctly, returned to the start and drove to the goal on the shortest known path. The ASCII maps he sent over Bluetooth made it easy to compare his internal model with reality.

Five maze layouts with the corresponding ASCII maps generated by the robot and run times between 12.59 and 28.79 seconds
Actual maze layouts (top), the ASCII maps generated by Jerry (bottom) and the measured run times.

The maze size is a compile-time parameter and the algorithms don't depend on it. A full 16 × 16 maze needs only a few kilobytes, which fits easily into the 16 KB of RAM. Only the fixed buffer sizes for the path and the BFS queue would have to grow before Jerry can take on a competition maze.

Lessons learned

  • Build incrementally. First each component on its own, then single moves and turns, and only then the full mapping. Testing many new parts at once makes errors very hard to find.
  • Measure, don't trust the datasheet. Sensors, motors and turns all deviated noticeably from their nominal values. Wherever possible, we used measured values instead.
  • Invest in debugging tools early. The Bluetooth command interface and the ASCII maze printouts helped us find most of our bugs.
  • C doesn't check array bounds. An inverted condition in the flood-fill neighbor check read outside of the maze array. The program did not crash, but read random values and occasionally made wrong driving decisions. Routing all neighbor accesses through a single validity check fixed it.
  • Design for measurement. Our test points were too small for the oscilloscope probe clamps, and measuring the battery voltage would have allowed adapting the PWM limit to the charge level.

What's next

There are several ways to make Jerry faster: equipping the two prepared diagonal sensor positions to detect junctions earlier, smooth turns while driving instead of stopping and turning in place, and better acceleration profiles on long straights. And, of course, Jerry still has to prove himself in a full 16 × 16 maze.

Building a robot from an empty schematic up to autonomous maze solving taught us a lot about PCB design, embedded programming and control engineering.

GitHub
LinkedIn