Dogs need exercise for their brains as well as their legs. Puzzle toys and training games keep them happy and calm, especially when they spend time indoors. In this capstone you build an electronic training game. Your dog learns to press a big button with its paw or nose, and a servo opens a flap that drops a treat. Three levels make it harder step by step. At level 1, every press earns a treat. At level 2, only presses while the green light is on count. At level 3, the cue is a sound instead of a light. You start, stop and watch every session from your phone, and the ESP32 keeps a diary: how many presses, how many were right, and how quickly your dog reacted to the cue.
What you'll learn
- How to design a game with levels, cues, time windows and rewards
- How to control a servo-driven treat flap from the ESP32
- How to measure reaction time with millis()
- How to keep a session diary in flash memory and show it on a web page
What you need
- ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
- 1 push button, the biggest you have, or a homemade pressure pad (ESP32 IoT Starter Kit)
- Green LED and 1 resistor, 220 Ω (ESP32 IoT Starter Kit)
- Servo motor SG90 (ESP32 IoT Starter Kit)
- Passive buzzer (ESP32 IoT Starter Kit)
- A cardboard tube, a small box and tape for the treat dispenser
- Small, soft training treats
- A phone on the same Wi-Fi
- Breadboard and jumper wires (ESP32 IoT Starter Kit)
Safety and kindness: stay with your dog during every session and keep all wires and the breadboard out of reach inside a closed box. Use tiny, soft treats that suit your dog, and count them as part of its daily food. Keep sessions short (a few minutes) and end while your dog is still having fun. Never punish a wrong press: the game simply doesn't reward it.
Wiring

| From | To |
|---|---|
| Button (paw pad), one leg | ESP32 GPIO 27 |
| Button, the diagonally opposite leg | GND |
| ESP32 GPIO 25 | 220 Ω resistor, then the green LED's long leg |
| LED short leg | GND |
| Servo signal (orange) | ESP32 GPIO 13 |
| Servo power (red) | ESP32 5V |
| Servo GND (brown) | GND |
| Passive buzzer +, − | ESP32 GPIO 33, GND |
Build the treat dispenser
- Stand a cardboard tube (for example from kitchen paper) upright on a small box, and cut a hole in the box top under the tube, so treats can fall through.
- Glue a stiff card flap to the servo horn and mount the servo so the flap closes the bottom of the tube. Test the angles:
CLOSEDcovers the hole,OPENuncovers it. - Cut a chute so the treat rolls out to the front. Fill the tube with a few treats.
- Make a big paw pad: tape the button under a coaster or a jar lid that your dog can easily press. Put the green LED next to it.

The sketch
Replace YOUR_WIFI_NAME and YOUR_WIFI_PASSWORD with your own network details (2.4 GHz only).
In the Arduino IDE, open Library Manager and install ESP32Servo by Kevin Harrington.
WebServer, ESPmDNS and Preferences come with the ESP32 board package. Adjust CLOSED and OPEN to your flap.
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <Preferences.h>
#include <ESP32Servo.h>
const char* WIFI_NAME = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const int BUTTON_PIN = 27, LED_PIN = 25, SERVO_PIN = 13, BUZZER_PIN = 33;
const int CLOSED = 0, OPEN = 70; // servo angles of the treat flap
const unsigned long CUE_TIME = 5000; // how long a cue lasts (levels 2 and 3)
const unsigned long MAX_SESSION = 600000;// a session ends after 10 minutes
WebServer server(80);
Preferences prefs;
Servo flap;
struct Session { uint32_t start; uint8_t level, treats; uint16_t presses, correct, avgReaction; };
Session history[10]; // the last 10 sessions
int historyCount = 0;
bool running = false;
Session cur;
int maxTreats = 8;
unsigned long sessionStart = 0, cueStart = 0, nextCue = 0, reactionSum = 0;
bool cueOn = false, lastButton = HIGH;
const char PAGE[] PROGMEM = R"rawliteral(<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Dog brain game</title>
<style>body{font-family:Arial,sans-serif;margin:0;padding:16px;background:#F6F8FC;color:#1B3A7B}
.card{background:#fff;border-radius:16px;padding:16px;margin:0 auto 14px;max-width:560px;box-shadow:0 2px 8px rgba(0,0,0,.08)}
button,select{font-size:17px;padding:10px 14px;border-radius:10px;border:0;margin:4px;background:#E5E7EB}
.go{background:#1E9E5A;color:#fff}.stop{background:#DE544A;color:#fff}.big{font-size:26px;font-weight:bold}
table{width:100%;border-collapse:collapse;font-size:14px}td,th{padding:6px;border-bottom:1px solid #eee;text-align:left}</style></head><body>
<div class="card"><div class="big" id="status">...</div><div id="live"></div></div>
<div class="card">Level <select id="level"><option value="1">1 - every press</option><option value="2">2 - wait for the light</option><option value="3">3 - wait for the sound</option></select>
Treats <select id="treats"><option>5</option><option selected>8</option><option>12</option></select><br>
<button class="go" onclick="go('/start?level='+level.value+'&treats='+treats.value)">Start</button>
<button class="stop" onclick="go('/stop')">Stop</button><button onclick="go('/treat')">Test treat</button></div>
<div class="card"><b>Diary</b><table><thead><tr><th>When</th><th>Level</th><th>Right</th><th>Treats</th><th>Reaction</th></tr></thead><tbody id="hist"></tbody></table></div>
<script>
var level = document.getElementById('level'), treats = document.getElementById('treats');
function go(u) { fetch(u).then(load); }
function load() {
fetch('/state').then(function (r) { return r.json(); }).then(function (s) {
document.getElementById('status').textContent = s.running ? 'Session running' + (s.cue ? ' - CUE ON!' : '') : 'Ready';
document.getElementById('live').textContent = s.running ? 'Level ' + s.level + ' - presses ' + s.presses + ', right ' + s.correct + ', treats ' + s.treats + ' of ' + s.max : '';
document.getElementById('hist').innerHTML = s.history.map(function (h) {
var d = new Date(h[0] * 1000), rate = h[2] ? Math.round(h[3] * 100 / h[2]) + '%' : '-';
return '<tr><td>' + d.toLocaleDateString([], { day: 'numeric', month: 'short' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + '</td><td>' + h[1] + '</td><td>' + rate + '</td><td>' + h[4] + '</td><td>' + (h[5] ? (h[5] / 1000).toFixed(1) + ' s' : '-') + '</td></tr>';
}).join('') || '<tr><td colspan="5">No sessions yet</td></tr>';
});
}
load(); setInterval(load, 1000);
</script></body></html>)rawliteral";
void giveTreat() {
flap.write(OPEN);
delay(600); // long enough for one treat to fall
flap.write(CLOSED);
}
void happySound() {
tone(BUZZER_PIN, 1047, 120);
delay(140);
tone(BUZZER_PIN, 1568, 180);
}
void loadHistory() {
historyCount = prefs.getBytes("hist", history, sizeof(history)) / sizeof(Session);
}
void endSession() {
running = false;
cueOn = false;
digitalWrite(LED_PIN, LOW);
cur.avgReaction = cur.correct && cur.level > 1 ? reactionSum / cur.correct : 0;
for (int i = 9; i > 0; i--) history[i] = history[i - 1]; // newest first
history[0] = cur;
if (historyCount < 10) historyCount++;
prefs.putBytes("hist", history, sizeof(Session) * historyCount);
Serial.printf("Session over: %d presses, %d right, %d treats\n", cur.presses, cur.correct, cur.treats);
}
void scheduleCue() {
nextCue = millis() + random(6000, 15000); // a surprise every 6 to 15 seconds
}
void handleStart() {
cur = {(uint32_t)time(nullptr), (uint8_t)constrain(server.arg("level").toInt(), 1, 3), 0, 0, 0, 0};
maxTreats = constrain(server.arg("treats").toInt(), 1, 20);
reactionSum = 0;
running = true;
sessionStart = millis();
cueOn = false;
digitalWrite(LED_PIN, cur.level == 1); // level 1: the light just stays on
scheduleCue();
server.send(200, "text/plain", "ok");
}
void handleState() {
String j = "{\"running\":" + String(running ? "true" : "false") + ",\"cue\":" + (cueOn ? "true" : "false") + ",\"level\":" + String(cur.level) + ",\"presses\":" + String(cur.presses) + ",\"correct\":" + String(cur.correct) + ",\"treats\":" + String(cur.treats) + ",\"max\":" + String(maxTreats) + ",\"history\":[";
for (int i = 0; i < historyCount; i++) {
Session& h = history[i];
j += (i ? "," : "") + String("[") + h.start + "," + h.level + "," + h.presses + "," + h.correct + "," + h.treats + "," + h.avgReaction + "]";
}
j += "]}";
server.send(200, "application/json", j);
}
void setup() {
Serial.begin(115200);
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
flap.attach(SERVO_PIN);
flap.write(CLOSED);
prefs.begin("doggame", false);
loadHistory();
randomSeed(esp_random());
WiFi.begin(WIFI_NAME, WIFI_PASSWORD);
Serial.print("Connecting");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
configTime(0, 0, "pool.ntp.org"); // the page shows times in your phone's time zone
MDNS.begin("doggame"); // http://doggame.local
server.on("/", []() { server.send_P(200, "text/html", PAGE); });
server.on("/state", handleState);
server.on("/start", handleStart);
server.on("/stop", []() { if (running) endSession(); server.send(200, "text/plain", "ok"); });
server.on("/treat", []() { giveTreat(); server.send(200, "text/plain", "ok"); });
server.begin();
Serial.print("\nOpen http://doggame.local or http://");
Serial.println(WiFi.localIP());
}
void loop() {
server.handleClient();
if (!running) return;
unsigned long now = millis();
// Levels 2 and 3: switch the cue on at a random moment, and off again after CUE_TIME
if (cur.level > 1) {
if (!cueOn && now >= nextCue) {
cueOn = true;
cueStart = now;
if (cur.level == 2) digitalWrite(LED_PIN, HIGH);
else tone(BUZZER_PIN, 1760, 300);
}
if (cueOn && now - cueStart > CUE_TIME) {
cueOn = false;
digitalWrite(LED_PIN, LOW);
scheduleCue();
}
}
bool button = digitalRead(BUTTON_PIN);
if (lastButton == HIGH && button == LOW) {
cur.presses++;
bool right = cur.level == 1 || cueOn;
if (right) {
cur.correct++;
if (cur.level > 1) reactionSum += now - cueStart;
happySound();
giveTreat();
cur.treats++;
if (cur.level > 1) {
cueOn = false;
digitalWrite(LED_PIN, LOW);
scheduleCue();
}
}
delay(50); // ignore the button bouncing
}
lastButton = button;
if (cur.treats >= maxTreats || now - sessionStart > MAX_SESSION) endSession();
}
Train your dog
- Upload the sketch and open the Serial Monitor at 115200 baud. Open the address from the Serial Monitor on your phone or computer, on the same Wi-Fi:
http://doggame.local, or the number it shows, such ashttp://192.168.1.57. - Tap Test treat to check that exactly one treat falls out. Adjust
OPENand the tube if it drops none or too many. - Level 1: start a session. Show your dog the pad, and when it touches it (by accident at first), it hears the happy sound and gets a treat. Most dogs understand within a few sessions.
- Level 2: now only presses while the green light is on are rewarded. The light comes on at random moments and stays on for 5 seconds.
- Level 3: the cue is a short beep instead of the light. Watch the diary: the percentage of right presses goes up, and the reaction time goes down as your dog learns.
How it works
Dog trainers call this operant conditioning: a behaviour followed by a reward becomes more frequent. The happy sound comes exactly when the dog does the right thing, a bit like a trainer's clicker, so the dog knows which action earned the treat. Level 2 adds a cue: the press only counts in a time window, so the dog learns to watch the light and wait, which is great practice in self-control.
The game is a loop of small timers. nextCue holds the moment the next cue should start, chosen randomly between 6 and 15 seconds so the dog can't simply guess the rhythm. While the cue is on, cueStart lets the sketch measure the reaction time of a right press with millis(). Every finished session goes into a struct with the level, presses, right presses, treats and the average reaction time. The last 10 sessions are saved with Preferences, newest first, and the page turns them into the diary table.
Try this next
- Add a level 4: two presses within 2 seconds.
- Use the PIR sensor to start a session automatically when your dog walks up to the game, but only twice a day.
- Show a progress chart: right presses in % per session over the last 10 sessions.
- Make a cat version with a smaller pad and a "feather" cue on a servo.
Troubleshooting
-
Too many treats fall at once: make the flap open for a shorter time (change
delay(600)to 300), or use a narrower tube. - The servo jitters or the ESP32 restarts: servos draw bursts of current. Use a good USB charger, and check the red wire is on 5V.
- Presses aren't counted: check the button wiring, and make the pad easy to press: the button must go down fully.
-
doggame.local doesn't open: some phones don't understand
.localnames. Type the number from the Serial Monitor instead, withhttp://in front. - It never connects to Wi-Fi: check the network name and password (both are case-sensitive) and make sure the network is 2.4 GHz.