Trò chơi đo phản xạ với ESP32 và màn hình OLED 4 nút: đo thời gian, tính trung bình
Làm trò chơi đo phản xạ bằng ESP32 và màn hình OLED kèm 4 nút: kiểm tra nút, đo thời gian bằng millis, chờ ngẫu nhiên, tính trung bình 5 lượt, thí nghiệm cho học sinh.
Màn hình hiện một ký hiệu, người chơi bấm đúng nút nhanh nhất có thể, mạch đo xem mất bao nhiêu mili giây. Trò chơi đo phản xạ rất hợp với học sinh: dễ làm, chơi được ngay, và mỗi lượt chơi là một lần đo đạc thật. Bài viết hướng dẫn làm trò chơi bằng ESP32 và màn hình OLED 0.96 inch có sẵn 4 nút nhấn, rồi dùng nó làm vài thí nghiệm khoa học nhỏ.
Qua dự án, trẻ học được cách máy tính đo thời gian, vì sao trò chơi cần số ngẫu nhiên, cách tính trung bình cộng từ nhiều lần đo, và cách đặt một câu hỏi rồi làm thí nghiệm để trả lời.
1. Chuẩn bị
- Một mạch ESP32 DevKit V1 và dây USB nạp code.
- Một màn hình OLED 0.96 inch SSD1306, chọn loại kèm 4 nút nhấn (hàng 8 chân: GND, VCC, SCL, SDA, K4, K3, K2, K1).
- Tám dây cắm test board loại hai đầu cái.
- Arduino IDE đã cài gói esp32, chọn board DOIT ESP32 DEVKIT V1. Vào Tools > Manage Libraries, tìm và cài thư viện Adafruit SSD1306; khi được hỏi, chọn cài kèm Adafruit GFX Library và Adafruit BusIO.
2. Nối dây

| Chân module | Chân ESP32 | Vai trò |
|---|---|---|
| GND | GND | nối đất |
| VCC | 3V3 | nguồn 3,3 V |
| SCL | D22 | xung nhịp I2C |
| SDA | D21 | dữ liệu I2C |
| K1 | D32 | nút K1 |
| K2 | D33 | nút K2 |
| K3 | D25 | nút K3 |
| K4 | D26 | nút K4 |
Màn hình nói chuyện với ESP32 qua I2C, chỉ cần hai dây SDA, SCL; D21 và D22 là hai chân I2C mặc định của ESP32. Bốn nút K1–K4 nối vào bốn chân bất kỳ còn trống. Lưu ý: mỗi lô mạch có thể đổi chỗ hai chân VCC và GND, nên luôn đọc chữ in cạnh hàng chân trên mạch của mình trước khi cắm dây nguồn.
3. Chương trình 1: kiểm tra 4 nút
Trên module, bốn nút được đánh dấu bằng ký hiệu ^ v # *, còn hàng chân ghi K1–K4. Chương trình nhỏ này giúp biết nút nào nối với chân nào, đồng thời kiểm tra dây và màn hình trước khi viết trò chơi.
// PYWORLD.VN - ESP32 + màn hình OLED 0.96 inch kèm 4 nút (K1–K4): kiểm tra nút
// OLED: SDA = D21, SCL = D22, địa chỉ 0x3C; nút: K1 = D32, K2 = D33, K3 = D25, K4 = D26
// Bấm lần lượt từng nút để biết nút nào là K1, K2, K3, K4 trên mạch của bạn
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
const int SO_NUT = 4;
const int CHAN_NUT[SO_NUT] = {32, 33, 25, 26}; // K1, K2, K3, K4
int mucNghi[SO_NUT]; // mức đọc được khi không bấm
void setup() {
Serial.begin(115200);
if (!oled.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println("Khong thay OLED, thu dia chi 0x3D");
}
oled.setTextColor(SSD1306_WHITE);
for (int i = 0; i < SO_NUT; i++) {
pinMode(CHAN_NUT[i], INPUT_PULLUP); // bật điện trở kéo lên bên trong ESP32
}
delay(50);
for (int i = 0; i < SO_NUT; i++) {
mucNghi[i] = digitalRead(CHAN_NUT[i]); // không bấm nút lúc khởi động
Serial.printf("K%d (chan %d) muc nghi: %d\n", i + 1, CHAN_NUT[i], mucNghi[i]);
}
}
void loop() {
oled.clearDisplay();
oled.setTextSize(1);
oled.setCursor(0, 0);
oled.print("Kiem tra 4 nut");
for (int i = 0; i < SO_NUT; i++) {
bool bam = digitalRead(CHAN_NUT[i]) != mucNghi[i]; // khác mức nghỉ là đang bấm
oled.setCursor(0, 16 + i * 12);
oled.printf("K%d chan %2d: %s", i + 1, CHAN_NUT[i], bam ? "BAM" : "-");
}
oled.display();
delay(50);
}
- Nạp chương trình khi không bấm nút nào. Lúc khởi động, chương trình ghi lại mức điện của từng chân (biến
mucNghi). Sau đó, chân nào có mức khác đi tức là nút đó đang được bấm. Nhờ vậy, chương trình không cần biết trước nút nối xuống GND hay lên nguồn. INPUT_PULLUPbật điện trở kéo lên có sẵn trong ESP32, để chân không bị "bỏ lửng" khi chưa bấm.- Bấm lần lượt từng nút và ghi lại: nút ^ là K mấy, nút v là K mấy… Nếu màn hình không sáng, mở Serial Monitor (115200 baud) xem dòng báo lỗi, và thử đổi địa chỉ
0x3Cthành0x3D.
4. Chương trình 2: trò chơi đo phản xạ

Mỗi ván có 5 lượt. Trong mỗi lượt, màn hình hiện "Cho..." trong một khoảng thời gian ngẫu nhiên, sau đó hiện to một ký hiệu. Người chơi bấm nút có ký hiệu đó. Hết 5 lượt, màn hình hiện thời gian trung bình (TB) và lượt nhanh nhất (Min).
// PYWORLD.VN - ESP32 + OLED 0.96 inch kèm 4 nút: trò chơi đo phản xạ
// Màn hình hiện một ký hiệu (^ v # *), bấm đúng nút càng nhanh càng tốt; 5 lượt, tính trung bình
// OLED: SDA = D21, SCL = D22; nút: K1 = D32, K2 = D33, K3 = D25, K4 = D26
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
const int SO_NUT = 4;
const int CHAN_NUT[SO_NUT] = {32, 33, 25, 26}; // K1, K2, K3, K4
const char KY_HIEU[SO_NUT] = {'^', 'v', '#', '*'}; // ký hiệu in cạnh K1..K4; sửa theo chương trình kiểm tra nút
const int SO_LUOT = 5;
int mucNghi[SO_NUT];
unsigned long tongThoiGian = 0, nhanhNhat = 0;
// Trả về số thứ tự nút đang bấm (0–3), hoặc -1 nếu không bấm nút nào
int nutDangBam() {
for (int i = 0; i < SO_NUT; i++) {
if (digitalRead(CHAN_NUT[i]) != mucNghi[i]) return i;
}
return -1;
}
void choNhaHet() {
while (nutDangBam() != -1) delay(10);
delay(30); // chờ hết dội phím
}
void hienChu(const char* dong1, const char* dong2, int co2) {
oled.clearDisplay();
oled.setTextSize(1);
oled.setCursor(0, 0);
oled.print(dong1);
oled.setTextSize(co2);
oled.setCursor(0, 24);
oled.print(dong2);
oled.display();
}
// Chơi một lượt; trả về thời gian phản xạ (ms), 0 nếu bấm sớm hoặc bấm sai
unsigned long choiMotLuot(int luot) {
char dong[24];
snprintf(dong, sizeof(dong), "Luot %d/%d", luot, SO_LUOT);
hienChu(dong, "Cho...", 2);
unsigned long batDauCho = millis();
unsigned long thoiGianCho = random(1500, 4000); // chờ ngẫu nhiên 1,5–4 giây
while (millis() - batDauCho < thoiGianCho) {
if (nutDangBam() != -1) { // bấm trước khi hiện ký hiệu
hienChu(dong, "Bam som!", 2);
choNhaHet();
delay(1000);
return 0;
}
}
int dich = random(SO_NUT);
char kyHieu[2] = {KY_HIEU[dich], 0};
oled.clearDisplay();
oled.setTextSize(6);
oled.setCursor(48, 12);
oled.print(kyHieu);
oled.display();
unsigned long batDau = millis();
int nut;
while ((nut = nutDangBam()) == -1) delay(1);
unsigned long phanXa = millis() - batDau;
choNhaHet();
if (nut != dich) {
hienChu(dong, "Sai nut!", 2);
delay(1000);
return 0;
}
snprintf(dong, sizeof(dong), "%lu ms", phanXa);
hienChu("Thoi gian phan xa:", dong, 3);
Serial.printf("Luot %d: %lu ms\n", luot, phanXa);
delay(1500);
return phanXa;
}
void setup() {
Serial.begin(115200);
oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
oled.setTextColor(SSD1306_WHITE);
for (int i = 0; i < SO_NUT; i++) pinMode(CHAN_NUT[i], INPUT_PULLUP);
delay(50);
for (int i = 0; i < SO_NUT; i++) mucNghi[i] = digitalRead(CHAN_NUT[i]);
}
void loop() {
hienChu("Do phan xa: bam dung", "Bam nut bat ky", 1);
while (nutDangBam() == -1) delay(10);
choNhaHet();
tongThoiGian = 0;
nhanhNhat = 0;
int luot = 1;
while (luot <= SO_LUOT) {
unsigned long t = choiMotLuot(luot);
if (t == 0) continue; // lượt hỏng: chơi lại lượt đó
tongThoiGian += t;
if (nhanhNhat == 0 || t < nhanhNhat) nhanhNhat = t;
luot++;
}
char dong[24];
oled.clearDisplay();
oled.setTextSize(1);
oled.setCursor(0, 0);
oled.print("Ket qua 5 luot");
oled.setTextSize(2);
oled.setCursor(0, 18);
snprintf(dong, sizeof(dong), "TB %lu", tongThoiGian / SO_LUOT);
oled.print(dong);
oled.setCursor(0, 40);
snprintf(dong, sizeof(dong), "Min %lu", nhanhNhat);
oled.print(dong);
oled.display();
Serial.printf("Trung binh %lu ms, nhanh nhat %lu ms\n", tongThoiGian / SO_LUOT, nhanhNhat);
delay(1000);
while (nutDangBam() == -1) delay(10); // bấm nút bất kỳ để chơi lại
choNhaHet();
}
Trước khi nạp, sửa mảng KY_HIEU theo kết quả của chương trình kiểm tra nút: phần tử thứ nhất là ký hiệu in cạnh nút K1, thứ hai là ký hiệu cạnh nút K2… Nếu không sửa, trò chơi vẫn chạy nhưng ký hiệu trên màn hình có thể không khớp với ký hiệu in cạnh nút.
- Đo thời gian bằng
millis(): hàm này trả về số mili giây kể từ lúc mạch khởi động. Ghi lạimillis()lúc ký hiệu hiện ra và lúc nút được bấm, lấy hiệu hai số là thời gian phản xạ. Vòng chờ nút códelay(1)nên số đo chính xác tới khoảng 1 ms. - Vì sao phải chờ ngẫu nhiên:
random(1500, 4000)cho một số từ 1500 đến 3999. Nếu lúc nào cũng chờ đúng 2 giây, người chơi sẽ đếm nhịp và bấm trước, kết quả không còn là phản xạ. - Chống gian lận: bấm nút trong lúc chờ thì màn hình báo "Bam som!" và lượt đó chơi lại. Bấm sai nút cũng chơi lại lượt.
- Chờ nhả nút: hàm
choNhaHet()chờ người chơi nhả hết nút rồi chờ thêm 30 ms. Nút cơ khí khi bấm, nhả thường "dội" vài lần trong vài mili giây; khoảng chờ này giúp một lần bấm không bị đếm thành nhiều lần.

5. Từ 5 lượt đến trung bình cộng

Mỗi lần đo cho một kết quả hơi khác, vì người chơi lúc tập trung, lúc lơ đãng. Vì vậy, nhà khoa học không dựa vào một lần đo mà đo nhiều lần rồi lấy trung bình cộng: cộng tất cả rồi chia cho số lần đo. Học sinh lớp 4 đã học phép tính này, và có thể tự tính lại để kiểm tra con số trên màn hình.
Chương trình chia hai số nguyên (tongThoiGian / SO_LUOT), nên phần thập phân bị bỏ đi. Ví dụ tổng 2633 ms chia 5 được 526,6 nhưng màn hình hiện 526.
6. Biến trò chơi thành thí nghiệm
Với người lớn, khi chỉ cần bấm một nút ngay khi thấy tín hiệu, thời gian phản xạ thường khoảng 0,2–0,3 giây. Trò chơi này khó hơn vì người chơi phải nhận ra ký hiệu và chọn đúng 1 trong 4 nút, nên kết quả thường lâu hơn. Gợi ý ba thí nghiệm; với mỗi thí nghiệm, cho trẻ đoán trước kết quả, chơi mỗi trường hợp ít nhất 2 ván, ghi TB vào bảng rồi so sánh:
- Tay thuận và tay không thuận: chơi một ván bằng tay phải, một ván bằng tay trái. Chênh nhau bao nhiêu mili giây?
- Một ký hiệu và bốn ký hiệu: đổi dòng
int dich = random(SO_NUT);thànhint dich = 0;để lần nào cũng chỉ hiện ký hiệu của nút K1. Khi không phải chọn nút, người chơi nhanh hơn bao nhiêu? Đây là ý chính của định luật Hick trong tâm lý học: càng nhiều lựa chọn, càng mất nhiều thời gian quyết định. - Luyện tập: chơi 5 ván liên tiếp, vẽ biểu đồ TB của từng ván. Kết quả có tốt dần lên không?
Số đo của trò chơi có một sai số nhỏ: màn hình cần một khoảng thời gian rất ngắn để vẽ xong ký hiệu. Sai số này giống nhau ở mọi lượt, nên vẫn so sánh công bằng giữa các lần chơi trên cùng một mạch.
7. Xử lý lỗi thường gặp
| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| Màn hình không sáng | Đảo VCC và GND; lỏng dây SDA, SCL; địa chỉ khác 0x3C | Đọc lại chữ in trên mạch; cắm chắc dây; thử địa chỉ 0x3D |
| Báo lỗi biên dịch không tìm thấy Adafruit_SSD1306.h | Chưa cài thư viện | Cài Adafruit SSD1306 kèm Adafruit GFX Library và Adafruit BusIO |
| Vừa khởi động đã báo "Bam som!" liên tục | Đang giữ nút lúc khởi động nên mức nghỉ bị ghi sai | Bỏ tay ra, bấm nút EN trên ESP32 để khởi động lại |
| Bấm nút mà dòng trong chương trình kiểm tra không đổi | Dây nút lỏng hoặc sai chân; nút nối lên nguồn mà mạch không có điện trở kéo xuống | Kiểm tra dây; thử đổi INPUT_PULLUP thành INPUT_PULLDOWN ở cả hai chương trình |
| Bấm đúng ký hiệu mà màn hình báo "Sai nut!" | Mảng KY_HIEU chưa sửa theo mạch | Chạy chương trình kiểm tra nút, sửa thứ tự trong KY_HIEU |
8. Gợi ý cho phụ huynh và giáo viên
- So với chính mình: khuyến khích trẻ ghi "kỷ lục cá nhân" và cố vượt qua nó, thay vì xếp hạng cả lớp. Phản xạ phụ thuộc nhiều vào tuổi và độ mệt, so sánh giữa các bạn dễ gây áp lực.
- Liên môn: Toán (trung bình cộng, biểu đồ cột), Khoa học (đặt giả thuyết, đo nhiều lần, so sánh), Tin học (biến, vòng lặp, câu lệnh điều kiện, số ngẫu nhiên).
- Phân vai: một bạn chơi, một bạn ghi số vào bảng, một bạn vẽ biểu đồ; sau mỗi ván đổi vai.
- Mở rộng cho học sinh lớn: thêm chế độ hai người chơi (mỗi người hai nút), hoặc lưu kỷ lục để tắt nguồn vẫn còn.
- Thời lượng: 2 buổi 45 phút. Buổi 1: nối dây, kiểm tra nút, chơi thử. Buổi 2: đọc chương trình, làm một thí nghiệm và trình bày kết quả.
9. Xem thêm
- Cách dùng thư viện vẽ chữ, hình trên màn hình OLED: màn hình OLED 0.96 inch SSD1306 với Arduino Nano (các lệnh của thư viện giống trên ESP32).
- Hai dây SDA, SCL hoạt động thế nào: giao tiếp I2C trên ESP32.
- Đọc nút nhấn nâng cao, chống dội: ngắt GPIO trên ESP32.
- Trong loạt phát triển tư duy cho trẻ bằng STEM và lập trình còn có: xúc xắc điện tử và xác suất với ESP32 và OLED (dùng lại đúng sơ đồ nối của bài này), đàn piano trái cây với ESP32, 5 dự án Scratch đầu tiên cho trẻ.
Xem thêm