A light-seeking robot uses two photoresistors (light-dependent resistors) positioned on opposite sides of its front face to measure the intensity of light arriving from each direction — the robot steers toward whichever side detects more light by driving the motor on that side faster, implementing a Braitenberg Vehicle Type 2 (crossed connections) in which higher light on the left directly speeds up the right motor, making the robot curve toward the brighter source as if magnetically attracted to it.
Introduction
Three robots in, and each has introduced a new dimension of autonomous behavior. The collision-avoiding rover reacts to what it encounters — an obstacle suddenly appearing within stopping distance. The line follower tracks a predefined path — a physical guide laid out in advance. The light-seeking robot does something more evocative: it actively seeks a goal in its environment, orienting and moving toward whatever is brightest.
Light-seeking is a behavior found throughout nature. Phototropic plants grow toward sunlight. Moths circle flames. Heliotropic flowers track the sun across the sky. What makes these behaviors interesting is that they emerge from remarkably simple mechanisms — a few chemical gradients, a handful of neurons — yet produce sophisticated-looking purposeful behavior. The same principle applies to your robot: from two photoresistors and a few lines of code, behavior emerges that looks, to an observer, strikingly like intention.
This article introduces the concept of Braitenberg Vehicles — thought experiments about simple sensor-to-motor connections that produce rich behavioral repertoires — and shows you how to implement several of them physically. You’ll build a light-seeker, then modify it into a light-avoider, then combine the two to produce a robot that seeks dim light while avoiding bright sources — a more nuanced behavior from the same hardware. Along the way, you’ll learn the general principles of stimulus-response programming that apply well beyond light sensing.
The Braitenberg Vehicle Framework
In 1984, Italian neuroscientist Valentino Braitenberg published a small but influential book called Vehicles: Experiments in Synthetic Psychology. In it, he described a series of imaginary autonomous vehicles whose behavior emerged entirely from direct connections between sensors and motors — no computation, no representation, no explicit goals. Yet these vehicles appeared to fear, love, pursue, flee, and explore.
The vehicles are numbered by complexity. Types 1 and 2 are the most relevant here:
Type 1: Direct Connection (Symmetric)
Left sensor ──────────────────────── Left motor
Right sensor ─────────────────────── Right motor
Light intensity directly drives motor speed.
More light everywhere = faster everywhere.
Both sensors equal = drives straight toward or away from light source.
Sensor asymmetry (source off-center) = slight steering, both motors active.
Behavior: Drives straight toward the source, accelerating as it approaches.
Appears to "charge" at the light.
If source is very bright and close: runs into it at high speed.
Type 2a: Crossed Connections (Love / Approach)
Left sensor ─────────────────────── Right motor (CROSSED)
Right sensor ────────────────────── Left motor (CROSSED)
More light on LEFT → RIGHT motor speeds up → robot turns LEFT (toward light)
More light on RIGHT → LEFT motor speeds up → robot turns RIGHT (toward light)
Behavior: Steers toward light source regardless of direction.
As it approaches and centers on source, sensors equalize, drives straight.
Slows as it arrives directly beneath the light (source overhead = equal sensors).
Appears to "seek" and "approach" — photophilic behavior.
Type 2b: Direct Connections (Fear / Avoidance)
Left sensor ─────────────────────── Left motor (DIRECT)
Right sensor ────────────────────── Right motor (DIRECT)
More light on LEFT → LEFT motor speeds up → robot turns RIGHT (away from light)
More light on RIGHT → RIGHT motor speeds up → robot turns LEFT (away from light)
Behavior: Steers away from light source.
Accelerates when exposed to bright light (faster escape).
Appears to "fear" the light — photophobic behavior.
The beauty of the Braitenberg framework is that these strikingly different behaviors emerge not from different programming but from different wiring — how sensors connect to motors. Your light-seeking robot will implement Type 2a. With a single change to which sensor drives which motor, it becomes a light-avoider.
Components List
The light-seeking robot reuses the same chassis, motors, and motor driver as the previous builds. The only new components are the light sensors:
New components:
- 2× photoresistors (LDRs — Light Dependent Resistors), GL5528 or similar
- 2× 10kΩ resistors (to form voltage dividers with the LDRs)
- Small enclosure or bracket to position the LDRs facing forward and outward
Reused from previous builds:
- Robot chassis with 2× TT motors and wheels
- Arduino Uno or Nano
- L298N motor driver module
- Battery pack (6× AA or 2S LiPo)
- Jumper wires
Total new cost: < $2 (photoresistors and resistors are among the cheapest components in electronics)
Understanding the Photoresistor
A photoresistor (also called LDR — Light Dependent Resistor, or photocell) is a passive component whose resistance decreases as light intensity increases. The GL5528 — the most common type in beginner electronics kits — has the following characteristics:
GL5528 photoresistor specifications:
Resistance in bright light (10 lux): ~8–20kΩ (depending on exact unit)
Resistance in dim light (1 lux): ~70–100kΩ
Resistance in darkness: 1MΩ+
Response time: ~20ms (rise), ~30ms (fall) — not for fast flashing
Spectral peak: ~540nm (green-yellow light, close to human eye peak)
Operating voltage: Any low DC voltage; typical 5V in divider circuits
Size: 5mm or 10mm diameter disc
Resistance-to-light relationship:
R_LDR ∝ 1 / lux^0.7 (approximately — response is non-linear, logarithmic)
This means:
- 10× brighter → resistance decreases to ~20% of previous value
- Not a precision instrument: ±20% variation unit-to-unit
- Temperature-dependent: resistance increases in cold
For a light-seeking robot: precision doesn't matter — only the difference
between left and right readings matters, not absolute values.
The Voltage Divider Circuit
A photoresistor by itself only tells you resistance — the Arduino ADC reads voltage. A voltage divider converts resistance to voltage:
VCC (5V) ──[R_fixed: 10kΩ]──┬──── Analog input (e.g. A0)
│
[LDR (R_variable)]
│
GND
V_out = 5V × R_LDR / (R_fixed + R_LDR)
In bright light: R_LDR ≈ 10kΩ → V_out = 5 × 10/(10+10) = 2.5V → ADC ≈ 511
In dim light: R_LDR ≈ 80kΩ → V_out = 5 × 80/(10+80) = 4.4V → ADC ≈ 904
In darkness: R_LDR ≈ 1MΩ → V_out = 5 × 1000/(10+1000) ≈ 4.95V → ADC ≈ 1012
Important: in this divider, V_out is HIGH in darkness and LOW in bright light.
To get a value that increases with brightness: brightness = 1023 - ADC_reading
Or: swap the positions of R_fixed and R_LDR (LDR on top → V_out high in bright light)
Which orientation you use is a matter of preference — just be consistent. The examples below use the divider as shown above (LDR on bottom, brighter = lower ADC reading) and compute brightness = 1023 - analogRead(pin) so that a higher brightness value represents more light.
Step 1: Wiring
The motor wiring is identical to the collision-avoiding rover. Add the two LDR voltage divider circuits:
Left LDR circuit:
5V ──[10kΩ]──┬── Arduino A0 (left light sensor)
│
[LDR_left]
│
GND
Right LDR circuit:
5V ──[10kΩ]──┬── Arduino A1 (right light sensor)
│
[LDR_right]
│
GND
LDR positioning: The LDRs must face outward to detect light from different directions. Place them symmetrically at the front of the robot, angled approximately 45° outward from the forward direction — one pointing forward-left, one forward-right. This gives the robot a wide field of view while maintaining directional sensitivity:
Top view of LDR placement:
↗ LDR_left (faces 45° forward-left)
──────────────────
│ ROBOT │
──────────────────
↘ LDR_right (faces 45° forward-right)
With this placement:
- A light source directly ahead → both sensors read equally → drives straight
- A light source to the left → LDR_left reads brighter → steers left (Type 2a)
- A light source to the right → LDR_right reads brighter → steers right (Type 2a)
- Light from behind → both sensors read dim → robot slows or stops
Secure the LDRs in place with hot glue, tape, or a small 3D-printed bracket. The orientation of the LDR face determines the robot’s directional sensitivity — small changes in angle produce noticeable behavior changes.
Step 2: Sensor Testing
Before writing control code, verify both sensors respond correctly and are balanced:
// LDR sensor test sketch
const int LDR_LEFT = A0;
const int LDR_RIGHT = A1;
void setup() {
Serial.begin(9600);
Serial.println(F("LDR Test — cover/uncover each sensor"));
}
void loop() {
int rawLeft = analogRead(LDR_LEFT);
int rawRight = analogRead(LDR_RIGHT);
// Convert to brightness (higher = brighter)
int brightLeft = 1023 - rawLeft;
int brightRight = 1023 - rawRight;
Serial.print(F("Left: "));
Serial.print(brightLeft);
Serial.print(F(" Right: "));
Serial.print(brightRight);
Serial.print(F(" Difference: "));
Serial.println(brightLeft - brightRight);
delay(200);
}
What to verify:
- Cover the left LDR → left brightness drops significantly (to near 0 in total darkness)
- Uncover → left brightness rises
- Repeat for right LDR
- Point the robot toward a bright light → both readings rise, roughly equally
- Angle the light to the left → left reading higher than right
- Angle the light to the right → right reading higher than left
If one sensor always reads much higher than the other in identical lighting, the LDRs are from different bins with different characteristics (common — LDRs have wide tolerance). Note the offset and compensate in code:
// Calibration offset to balance sensors
// Set this to (left_in_identical_light - right_in_identical_light) / 2
const int BALANCE_OFFSET = 30; // Example: left reads 30 higher than right
int calibratedLeft = brightLeft - BALANCE_OFFSET;
int calibratedRight = brightRight + BALANCE_OFFSET;
Or run an automatic calibration at startup:
void calibrateSensors(int &leftOffset, int &rightOffset) {
// Average 50 readings in current light conditions
long sumLeft = 0, sumRight = 0;
for (int i = 0; i < 50; i++) {
sumLeft += 1023 - analogRead(LDR_LEFT);
sumRight += 1023 - analogRead(LDR_RIGHT);
delay(20);
}
int avgLeft = sumLeft / 50;
int avgRight = sumRight / 50;
// Compute offsets to equalize sensors at current ambient light
int avgBoth = (avgLeft + avgRight) / 2;
leftOffset = avgBoth - avgLeft; // Add to left reading
rightOffset = avgBoth - avgRight; // Add to right reading
Serial.print(F("Calibration: left offset="));
Serial.print(leftOffset);
Serial.print(F(" right offset="));
Serial.println(rightOffset);
}
Step 3: The Light-Seeker (Braitenberg Type 2a)
With sensors tested and balanced, implement the crossed-connection light-seeker:
/*
* Light-Seeking Robot — Braitenberg Vehicle Type 2a
*
* Crossed connections: left sensor → right motor, right sensor → left motor
* Result: robot steers toward the brightest light source
*
* Hardware:
* LDR_left: A0, voltage divider with 10kΩ to 5V
* LDR_right: A1, voltage divider with 10kΩ to 5V
* Motors: same as collision-avoiding rover (pins 5–10)
*/
// Motor pins (same as Articles 75–76)
const int IN1 = 5, IN2 = 6, ENA = 9;
const int IN3 = 7, IN4 = 8, ENB = 10;
// Sensor pins
const int LDR_LEFT = A0;
const int LDR_RIGHT = A1;
// Behavior parameters
const int MIN_SPEED = 60; // Minimum motor speed (prevent stall, keep moving)
const int MAX_SPEED = 220; // Maximum motor speed
const int DARK_THRESH = 50; // Below this brightness: "dark" — stop or wander
const float GAIN = 0.8; // How strongly light difference drives steering
const int SMOOTH_N = 5; // Readings to average per sensor per loop
// Calibration offsets (set from calibration run or leave at 0)
int leftOffset = 0;
int rightOffset = 0;
// Motor control
void setMotors(int leftPWM, int rightPWM) {
leftPWM = constrain(leftPWM, 0, MAX_SPEED);
rightPWM = constrain(rightPWM, 0, MAX_SPEED);
analogWrite(ENA, leftPWM);
digitalWrite(IN1, HIGH);
digitalWrite(IN2, LOW);
analogWrite(ENB, rightPWM);
digitalWrite(IN3, HIGH);
digitalWrite(IN4, LOW);
}
void stopMotors() {
analogWrite(ENA, 0);
analogWrite(ENB, 0);
}
// Averaged sensor read
int readBrightness(int pin, int n = SMOOTH_N) {
long sum = 0;
for (int i = 0; i < n; i++) {
sum += 1023 - analogRead(pin); // Invert: high = bright
delay(5);
}
return sum / n;
}
void setup() {
pinMode(IN1, OUTPUT); pinMode(IN2, OUTPUT); pinMode(ENA, OUTPUT);
pinMode(IN3, OUTPUT); pinMode(IN4, OUTPUT); pinMode(ENB, OUTPUT);
stopMotors();
Serial.begin(9600);
Serial.println(F("Light-Seeking Robot — initializing..."));
// Auto-calibrate in ambient light (robot should be in typical environment, no directed light)
calibrateSensors(leftOffset, rightOffset);
delay(1000);
Serial.println(F("Ready. Point a flashlight to steer!"));
}
void loop() {
// Read and calibrate sensor brightnesses
int brightLeft = readBrightness(LDR_LEFT) + leftOffset;
int brightRight = readBrightness(LDR_RIGHT) + rightOffset;
brightLeft = constrain(brightLeft, 0, 1023);
brightRight = constrain(brightRight, 0, 1023);
int totalLight = brightLeft + brightRight;
// Debug output
Serial.print(F("L:"));
Serial.print(brightLeft);
Serial.print(F(" R:"));
Serial.print(brightRight);
Serial.print(F(" Diff:"));
Serial.println(brightLeft - brightRight);
// If overall scene is very dark: wander (slow random-ish movement)
if (totalLight < DARK_THRESH * 2) {
// Wander: slow forward with gentle oscillation
setMotors(MIN_SPEED + 20, MIN_SPEED);
return;
}
// ── Braitenberg Type 2a: CROSSED connections ────────────────────
// Left sensor → RIGHT motor (more left light = faster right = turns left)
// Right sensor → LEFT motor (more right light = faster left = turns right)
// Map brightness (0–1023) to motor speed (MIN_SPEED–MAX_SPEED)
int rightMotorSpeed = map(brightLeft, 0, 1023, MIN_SPEED, MAX_SPEED);
int leftMotorSpeed = map(brightRight, 0, 1023, MIN_SPEED, MAX_SPEED);
setMotors(leftMotorSpeed, rightMotorSpeed);
}
Testing the Light Seeker
Place the robot on a smooth floor in a room with consistent ambient light. Take a flashlight and point it at the robot from different angles:
- From directly ahead: Both sensors brighten equally. Robot drives forward toward the light.
- From the left side: Left sensor brightens. Right motor speeds up (crossed connection). Robot turns left — toward the source.
- From the right side: Right sensor brightens. Left motor speeds up. Robot turns right.
- Moving the flashlight in a circle: The robot tracks it, pivoting to stay aimed at the beam.
- Very dim room, flashlight off: Robot wanders slowly (the dark-wander behavior).
The behavior is striking and immediately intuitive to observers who know nothing about the implementation — the robot appears to want the light.
Step 4: The Light-Avoider (Braitenberg Type 2b)
Change just the motor assignments — make the connections direct instead of crossed — and the robot becomes a light-avoider:
// ── Braitenberg Type 2b: DIRECT connections ────────────────────
// Left sensor → LEFT motor (more left light = faster left = turns right = AWAY from left)
// Right sensor → RIGHT motor (more right light = faster right = turns left = AWAY from right)
// (Replace the motor assignment lines in loop() with these:)
int leftMotorSpeed = map(brightLeft, 0, 1023, MIN_SPEED, MAX_SPEED);
int rightMotorSpeed = map(brightRight, 0, 1023, MIN_SPEED, MAX_SPEED);
setMotors(leftMotorSpeed, rightMotorSpeed);
That’s the entire change — two lines swap. The robot now runs away from the flashlight. Point it from the left and the robot veers right. Chase it with the beam and it accelerates away. The more intense the light, the more urgently it flees.
Step 5: Combined Behavior — Seeking Comfortable Light
By combining sensing, thresholds, and mode-switching, you can create a robot that seeks light when it’s too dim but avoids it when it’s too bright — settling into a comfortable middle range:
// Light-comfort robot: seeks dim light, avoids bright light
const int TOO_DARK = 100; // Below this: seek more light (type 2a behavior)
const int TOO_BRIGHT = 700; // Above this: avoid light (type 2b behavior)
// Between 100 and 700: comfortable — drive forward slowly
void loop() {
int brightLeft = readBrightness(LDR_LEFT) + leftOffset;
int brightRight = readBrightness(LDR_RIGHT) + rightOffset;
int avgBright = (brightLeft + brightRight) / 2;
if (avgBright < TOO_DARK) {
// Too dark — seek light (crossed connections: type 2a)
int rightMotorSpeed = map(brightLeft, 0, 1023, MIN_SPEED, MAX_SPEED);
int leftMotorSpeed = map(brightRight, 0, 1023, MIN_SPEED, MAX_SPEED);
setMotors(leftMotorSpeed, rightMotorSpeed);
Serial.println(F("SEEKING"));
} else if (avgBright > TOO_BRIGHT) {
// Too bright — avoid light (direct connections: type 2b)
int leftMotorSpeed = map(brightLeft, 0, 1023, MIN_SPEED, MAX_SPEED);
int rightMotorSpeed = map(brightRight, 0, 1023, MIN_SPEED, MAX_SPEED);
setMotors(leftMotorSpeed, rightMotorSpeed);
Serial.println(F("AVOIDING"));
} else {
// Comfortable light level — cruise slowly forward
setMotors(MIN_SPEED + 30, MIN_SPEED + 30);
Serial.println(F("COMFORTABLE"));
}
}
This three-mode behavior produces a robot that explores its environment, moving toward light sources until they’re too intense, then retreating until the intensity drops back into the comfortable range. In a room with a window and shadowed corners, the robot naturally gravitates toward the window-lit zone — but not so close that it’s fully exposed to direct sunlight.
This is a simple implementation of optotaxis — orientation and movement with respect to light — and closely parallels the behavior of many organisms that seek a preferred light intensity range for thermoregulation, photosynthesis, or camouflage.
Step 6: Adding Obstacle Avoidance
The light-seeking robot ignores physical obstacles entirely — it will drive into a wall while pursuing a flashlight. Combining light-seeking with obstacle avoidance from Article 75 creates a more complete autonomous agent:
// Combined: light-seeking + collision avoidance
const int OBSTACLE_THRESH = 20.0; // cm
void loop() {
float distance = getSmoothedDistance(); // HC-SR04 from Article 75 code
if (distance < OBSTACLE_THRESH) {
// Obstacle priority: avoid first
stopMotors();
delay(200);
driveBackward(150);
delay(500);
turnRight(150);
delay(600);
stopMotors();
return; // Skip light-seeking this iteration
}
// No obstacle: seek light normally
int brightLeft = readBrightness(LDR_LEFT) + leftOffset;
int brightRight = readBrightness(LDR_RIGHT) + rightOffset;
int rightMotorSpeed = map(brightLeft, 0, 1023, MIN_SPEED, MAX_SPEED);
int leftMotorSpeed = map(brightRight, 0, 1023, MIN_SPEED, MAX_SPEED);
setMotors(leftMotorSpeed, rightMotorSpeed);
}
This priority-based behavior architecture — obstacle avoidance interrupts and overrides the light-seeking goal — is a simplified version of the subsumption architecture developed by Rodney Brooks at MIT in the 1980s. In subsumption, behaviors are layered by priority: safety behaviors at the bottom (highest priority, always active), goal behaviors above (lower priority, active when safety behaviors don’t preempt them). The obstacle avoider subsumes the light seeker in this implementation.
Understanding What Makes Behavior “Autonomous”
The light-seeking robot prompts a useful reflection on what autonomy in robotics actually means.
Three Requirements for Autonomy
A robot behaves autonomously when:
- It acts without moment-to-moment human input. The flashlight demonstrates direction but the robot decides how to respond. No human is sending motor commands.
- Its behavior adapts to sensed conditions. The robot in a dark room behaves differently than in a bright room because it reads the environment and responds. Hard-coded sequences (go forward for 3 seconds, turn right) are not adaptive.
- The behavior arises from the robot’s own sensing and processing. The robot generates appropriate outputs from inputs — it doesn’t receive outputs from an external controller.
The light-seeker meets all three. But it’s a limited autonomy — it has one goal (find light), one sensing modality (brightness), and one output (wheel speed). Real autonomous robots have multiple goals, multiple sensing modalities, and must manage conflicts between them.
Emergent Behavior
Perhaps the most intellectually interesting aspect of the Braitenberg vehicle is that the behavior emerges from the structure of connections rather than being explicitly programmed. The robot doesn’t have a goal representation (no variable called myGoal = "find light"). It doesn’t plan (no path planning, no lookahead). It doesn’t model the world (no map, no object representation).
And yet it seeks. The appearance of purposeful behavior from non-purposeful mechanisms is called emergence — and it’s a recurring theme in robotics, artificial intelligence, and biology. Understanding that complex behavior can emerge from simple mechanisms (rather than requiring explicit representation and planning) is one of the most valuable insights early robotics projects can provide.
Expanding the Sensor Suite
The photoresistor is just one of many analog sensors that can drive Braitenberg-style reactive behaviors. Once you understand the pattern — sensor → mapping → motor — you can apply it to any sensor:
Sensor Measurement Possible behavior
──────────────────────────────────────────────────────────────────
LDR Light intensity Seek or avoid bright regions
Thermistor Temperature Seek warm areas (thermotaxis)
Microphone Sound volume Orient toward sound sources (phonotaxis)
IR distance Obstacle range Gradient descent away from walls
Soil moisture Humidity Seek damp areas (hygrotaxis)
Chemical sensor Gas concentration Seek or avoid chemical gradients (chemotaxis)
Compass (HMC) Magnetic heading Maintain constant heading (magnetotaxis)
The Braitenberg framework generalizes completely to any of these. A temperature-seeking robot wires thermistor readings into motor speeds exactly as the LDR example does. A sound-following robot replaces the LDR voltage with microphone amplitude. The control code structure is identical — only the sensor reading changes.
The Microphone Sound-Follower
A quick implementation for following sound intensity:
// Sound-seeking robot (Braitenberg Type 2a with microphone inputs)
// Uses two electret microphone modules with analog output
const int MIC_LEFT = A0;
const int MIC_RIGHT = A1;
const int SILENCE = 512; // ADC value at silence (mid-rail for AC-coupled mic)
int readSoundLevel(int pin) {
// Sound level = deviation from silence (RMS approximation: peak detection)
int peak = 0;
for (int i = 0; i < 100; i++) { // Sample for ~10ms
int sample = abs(analogRead(pin) - SILENCE);
if (sample > peak) peak = sample;
}
return peak; // 0–511, higher = louder
}
void loop() {
int soundLeft = readSoundLevel(MIC_LEFT);
int soundRight = readSoundLevel(MIC_RIGHT);
// Crossed connections: louder on left → faster right → turns left (toward sound)
int rightMotorSpeed = map(soundLeft, 0, 511, MIN_SPEED, MAX_SPEED);
int leftMotorSpeed = map(soundRight, 0, 511, MIN_SPEED, MAX_SPEED);
setMotors(leftMotorSpeed, rightMotorSpeed);
}
Clap loudly to the left of the robot and it turns toward you. Clap to the right and it turns that way. The same four motor lines from the light-seeker; only readBrightness() is replaced with readSoundLevel().
Troubleshooting Reference
| Symptom | Likely Cause | Fix |
|---|---|---|
| Robot always turns in same direction | LDR offset large; one sensor much brighter | Run calibration; add offset correction |
| Robot doesn’t respond to flashlight | LDR connections wrong or sensors behind opaque enclosure | Verify A0/A1 readings change when covering/uncovering LDR |
| Robot drives in reverse | Motor direction connections inverted | Swap IN1/IN2 (or IN3/IN4) for the inverted motor |
| Robot seeks light but crashes into walls | No obstacle avoidance | Add HC-SR04 and priority-based behavior from Step 6 |
| Robot spins in circles | Sensors on wrong sides (left LDR wired to right motor position) | Swap LDR wire to A0 and A1, or swap motor connections |
| Robot ignores flashlight in bright room | Ambient light overpowers directional signal | Test in dimmer room; flashlight needs to be much brighter than ambient |
| Robot jitters rapidly | Sensing noise causing rapid motor speed changes | Increase SMOOTH_N averaging; add EMA filter to brightness readings |
| One LDR reads 0 constantly | Broken LDR or wiring fault | Measure voltage at LDR mid-point with multimeter — should vary with light |
The light-seeking robot introduces three concepts that extend well beyond this single project.
First, reactive behavior architecture: sensing directly drives actuation through a mapping function, producing behavior that adapts continuously to environmental conditions without planning, models, or explicit goal representations. This is fast, robust, and requires minimal computation — and it underlies much of what makes simple autonomous robots work reliably.
Second, the Braitenberg framework: the same hardware, the same sensors, the same motors produce qualitatively different behaviors (seeking vs. avoiding, comfortable range-finding) based solely on how sensors connect to motors and what threshold logic surrounds that connection. Behavior is structure, not just code.
Third, priority-based behavior composition: combining light-seeking with obstacle avoidance through a simple priority scheme (safety first, goal second) demonstrates how multiple behaviors can coexist in a single robot by establishing clear precedence rules. This hierarchical principle scales from this two-behavior example all the way to sophisticated autonomous systems managing dozens of simultaneous objectives.
Deeper Behavior Engineering: State Machines for the Light Seeker
The basic light-seeker drives continuously in response to instantaneous sensor readings. Adding a state machine allows richer behavior — the robot can have distinct modes with transitions between them, making its behavior more nuanced and predictable:
/*
* Light-Seeking Robot with State Machine
* States: WANDERING, SEEKING, RESTING
*
* WANDERING: ambient light low, no clear direction → wander slowly
* SEEKING: light detected with clear direction → pursue actively
* RESTING: very bright, centered under light source → stop and "bask"
*/
enum RobotState {
STATE_WANDERING,
STATE_SEEKING,
STATE_RESTING
};
RobotState currentState = STATE_WANDERING;
unsigned long stateStartTime = 0;
const int WANDER_THRESH = 150; // Total brightness below which: wander
const int SEEK_THRESH = 150; // Above this and difference > MIN_DIFF: seek
const int REST_THRESH = 800; // Total brightness above this AND small difference: rest
const int MIN_DIFF = 80; // Minimum brightness difference to have clear direction
const int REST_MAX_DIFF = 60; // If centered (diff < this) AND very bright: rest
void transitionTo(RobotState newState) {
if (newState != currentState) {
currentState = newState;
stateStartTime = millis();
Serial.print(F("→ State: "));
Serial.println(newState == STATE_WANDERING ? F("WANDERING") :
newState == STATE_SEEKING ? F("SEEKING") : F("RESTING"));
}
}
void loop() {
// Read sensors
int brightLeft = readBrightness(LDR_LEFT) + leftOffset;
int brightRight = readBrightness(LDR_RIGHT) + rightOffset;
brightLeft = constrain(brightLeft, 0, 1023);
brightRight = constrain(brightRight, 0, 1023);
int totalBright = brightLeft + brightRight;
int diff = abs(brightLeft - brightRight);
// ── State transitions ──────────────────────────────────────────
if (totalBright < WANDER_THRESH) {
transitionTo(STATE_WANDERING);
} else if (totalBright > REST_THRESH && diff < REST_MAX_DIFF) {
transitionTo(STATE_RESTING);
} else if (totalBright >= SEEK_THRESH && diff >= MIN_DIFF) {
transitionTo(STATE_SEEKING);
} else if (totalBright >= SEEK_THRESH && diff < MIN_DIFF) {
// Bright but centered — keep seeking (heading toward source)
transitionTo(STATE_SEEKING);
}
// ── State behaviors ────────────────────────────────────────────
switch (currentState) {
case STATE_WANDERING: {
// Meander: alternate gentle curves every 1.5 seconds
unsigned long elapsed = millis() - stateStartTime;
if ((elapsed / 1500) % 2 == 0) {
setMotors(MIN_SPEED + 30, MIN_SPEED); // Gentle right curve
} else {
setMotors(MIN_SPEED, MIN_SPEED + 30); // Gentle left curve
}
break;
}
case STATE_SEEKING: {
// Braitenberg Type 2a: crossed connections
int rightMotorSpeed = map(brightLeft, 0, 1023, MIN_SPEED, MAX_SPEED);
int leftMotorSpeed = map(brightRight, 0, 1023, MIN_SPEED, MAX_SPEED);
setMotors(leftMotorSpeed, rightMotorSpeed);
break;
}
case STATE_RESTING: {
// Stop and wait — "basking" in the light
stopMotors();
// If resting for more than 3 seconds and still bright: stay resting
// (transitions handled in the transition logic above)
break;
}
}
// Debug output
Serial.print(F("L:")); Serial.print(brightLeft);
Serial.print(F(" R:")); Serial.print(brightRight);
Serial.print(F(" T:")); Serial.print(totalBright);
Serial.print(F(" D:")); Serial.println(diff);
}
This state machine version produces distinctly more life-like behavior. The robot wanders when lost in darkness. When a light source is detected, it switches to active pursuit. When it arrives directly under a strong light with balanced sensors (the “centered” condition), it stops and rests. Remove the light and it eventually wanders again.
The wandering-seeking-resting cycle is evocative of animal foraging behavior — searching when resources aren’t available, actively pursuing when they’re detected, stopping to consume when they’re acquired. This parallel isn’t accidental: the simplest useful behaviors for autonomous agents, whether biological or robotic, tend to converge on similar structures.
Calibration for Different Lighting Environments
LDRs are highly sensitive to ambient lighting conditions, and behavior that works perfectly indoors in the evening may fail in a brightly lit room where the ambient light overwhelms any directional signal. These calibration strategies help:
Dynamic Range Calibration
Measure the minimum and maximum brightness the sensors encounter in the current environment, then scale readings to fill the full 0–1023 range dynamically:
// Dynamic range calibration — run in setup() after robot is placed in environment
int minLeft = 1023, maxLeft = 0;
int minRight = 1023, maxRight = 0;
void autoRangeCalibrate(int durationMs = 3000) {
Serial.println(F("Calibrating... wave robot around for 3 seconds"));
unsigned long start = millis();
while (millis() - start < durationMs) {
int l = 1023 - analogRead(LDR_LEFT);
int r = 1023 - analogRead(LDR_RIGHT);
minLeft = min(minLeft, l);
maxLeft = max(maxLeft, l);
minRight = min(minRight, r);
maxRight = max(maxRight, r);
delay(20);
}
Serial.print(F("Left range: ")); Serial.print(minLeft); Serial.print(F("–")); Serial.println(maxLeft);
Serial.print(F("Right range: ")); Serial.print(minRight); Serial.print(F("–")); Serial.println(maxRight);
}
// During normal operation: scale to 0–1023 using measured range
int scaledLeft() { return map(1023 - analogRead(LDR_LEFT), minLeft, maxLeft, 0, 1023); }
int scaledRight() { return map(1023 - analogRead(LDR_RIGHT), minRight, maxRight, 0, 1023); }
Wave the robot around slowly during the calibration period to expose both sensors to the full range of light in the environment. After calibration, the sensors’ outputs span 0–1023 regardless of absolute light level — making the behavior consistent across different rooms and lighting conditions.
Measuring Sensor Directionality
Not all LDR placements are equally effective. The directional sensitivity depends on how the LDR is angled. A quick experiment reveals the actual angular sensitivity of your placement:
// Directional sensitivity test
// Rotate robot slowly in place, record both sensor readings
// Plot the result to see the angular sensitivity pattern
void measureDirectionality() {
Serial.println(F("angle,left,right,diff"));
for (int angle = 0; angle < 360; angle += 10) {
// Rotate 10° steps manually, pressing a button or waiting for input
Serial.print(angle);
Serial.print(F(","));
int l = readBrightness(LDR_LEFT);
int r = readBrightness(LDR_RIGHT);
Serial.print(l);
Serial.print(F(","));
Serial.print(r);
Serial.print(F(","));
Serial.println(l - r);
delay(2000); // 2 seconds to rotate to next position
}
}
Open the Serial Monitor, copy the output to a spreadsheet, and plot diff (left minus right) versus angle. The ideal result is a sinusoidal pattern — maximum positive at 90° left, maximum negative at 90° right, zero at 0° (directly ahead) and 180° (directly behind). If the zero crossing is at an angle other than 0°, adjust the LDR mounting angle accordingly.
A sharp, pronounced sinusoid means high directional sensitivity — the robot can localize light sources precisely. A flat, weak sinusoid means poor directional sensitivity — adjust LDR angle outward (more toward 90°) to increase it.
The Light Seeker in Educational Context
The light-seeking robot has an unusually rich educational value beyond the technical skills it teaches, because it illustrates profound principles from biology, psychology, and philosophy:
It challenges assumptions about intentionality. A person watching the robot for the first time typically describes it as “wanting” or “looking for” the light. The robot appears purposeful. Yet there is no purpose representation anywhere in the code — no goal variable, no desired state, no planning. The appearance of intention emerges from mechanism. This is precisely the claim that Braitenberg and many others have made about biological behavior: that what appears purposeful at the behavioral level arises from mechanisms that are not themselves purposeful.
It demonstrates the power of simple rules. The entire behavioral repertoire — seeking, avoiding, comfortable-range settling — requires fewer than 30 lines of active logic (not counting motor control functions). Simple rules applied to continuous sensing produce behavior that is surprising in its richness. This is a lesson that scales: many apparently complex robotic behaviors can be decomposed into simple, local rules.
It makes reactive architecture tangible. The distinction between reactive (sense-act directly) and deliberative (sense-model-plan-act) architecture becomes concrete when you’ve built a reactive robot and considered what it would take to add the missing elements. The light-seeker reacts correctly to the immediate environment but has no memory of where it has been, no model of the room, and no ability to plan a route. Adding these capabilities is the road from reactive robotics to cognitive robotics — a road that this simple robot makes visible.



