Which AI is better, ChatGPT or Claude? People argue about it all the time, but most of them already know which answer came from which AI, and that changes what they think. Scientists avoid this with a blind test: you judge without knowing what you are judging. In this lesson your ESP32 runs one. You choose a task with the joystick, such as "Tiny poem" or "Weather report". The ESP32 reads the temperature and humidity with a DHT11 and gives exactly the same readings and task to ChatGPT and to Claude. The answers appear on the LCD as A and B in a random order. You read both, vote for the one you like, and only then does the LCD tell you who wrote it. The score is saved in the ESP32's memory, so your family or class can keep testing for weeks.
What you'll learn
- What a blind test is and why it is fairer
- How to give two AIs exactly the same question
- How to read a joystick on the ESP32
- How to keep a score in flash memory with Preferences
- Why one vote proves little, and many votes tell you more
What you need
- ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
- 1602 LCD display, 10 kΩ potentiometer and 1 resistor, 220 Ω (ESP32 IoT Starter Kit)
- Joystick module (ESP32 IoT Starter Kit)
- DHT11 temperature and humidity module (ESP32 IoT Starter Kit)
- An OpenAI API key (for ChatGPT) and a Claude API key, from an adult's accounts
- A 2.4 GHz Wi-Fi network
- Breadboard and jumper wires (ESP32 IoT Starter Kit)
Wiring

Build it step by step
Plug in all the parts first, then add the wires in this order. In each picture the new wires are bright and the ones from earlier steps are faded. Click a picture to see it larger.




| From | To |
|---|---|
| LCD VSS, RW and K | GND |
| LCD VDD | 5V |
| LCD V0 | Middle pin (W) of the potentiometer |
| LCD RS | ESP32 GPIO 19 |
| LCD E | ESP32 GPIO 23 |
| LCD D4, D5, D6, D7 | ESP32 GPIO 18, 17, 16, 4 |
| LCD D0 to D3 | Not connected |
| LCD A (backlight +) | 220 Ω resistor, then 5V |
| Potentiometer outer pins | 5V and GND |
| Joystick VRx | ESP32 GPIO 34 |
| Joystick VRy | ESP32 GPIO 35 |
| Joystick SW | ESP32 GPIO 32 |
| Joystick +5V and DHT11 + | ESP32 3V3 (the ESP32 can only measure up to 3.3 V) |
| Joystick GND and DHT11 − | GND |
| DHT11 S (data) | ESP32 GPIO 27 |
The LCD is wired as in the AI chat buddy. It runs on 5V but only listens to the ESP32, so its 5V never reaches the ESP32's pins. The joystick and the DHT11 run on 3V3 instead, from a short wire to the ESP32's 3V3 pin, because they talk back to the ESP32.
Sensor modules from different makers can label and order their pins differently. Always follow the labels printed on your module.
Get the two API keys
ChatGPT is made by OpenAI and Claude by Anthropic. You know them as chat apps, but a board can reach the same AI models through each company's API. Both APIs are paid services that charge a tiny amount per question, and both accounts must belong to an adult (18+), so ask a parent or teacher to do these steps with you.
-
For ChatGPT: create an account at platform.openai.com, add a small amount of credit under Billing and set a low monthly budget in the settings. Under API keys, create a new secret key and copy it. It starts with
sk-. -
For Claude: create an account at console.anthropic.com, add a small amount of credit and set a low monthly spend limit under Billing. Under API keys, create a key and copy it. It starts with
sk-ant-. - An API key works like a password and a credit card in one. Never share it, never post your sketch online with a key in it, and delete the keys when the project is finished.
This test needs both keys. With only one, the LCD shows the error and goes back to the menu.
The sketch
In the Arduino IDE, open Library Manager and install the ArduinoJson library by Benoit Blanchon and the DHT sensor library by Adafruit. Preferences comes with the ESP32 board package. LiquidCrystal comes with the Arduino IDE.
Replace YOUR_WIFI_NAME and YOUR_WIFI_PASSWORD with your own network details (2.4 GHz only).
Paste your two keys into OPENAI_KEY and CLAUDE_KEY. To start a new score, hold the joystick button down while you press the ESP32's EN (reset) button, and keep holding it until the LCD says "Score reset", then let go.
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <LiquidCrystal.h>
#include <DHT.h>
#include <Preferences.h>
const char* WIFI_NAME = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* OPENAI_KEY = "YOUR_OPENAI_API_KEY"; // for ChatGPT: keep it secret!
const char* OPENAI_MODEL = "gpt-6-luna";
const char* CLAUDE_KEY = "YOUR_ANTHROPIC_API_KEY"; // for Claude: keep it secret!
const char* CLAUDE_MODEL = "claude-opus-5-5";
const char* SYSTEM = "You are taking part in a friendly writing test judged by children and their families. "
"You get readings from sensors in a room and a task. Do the task based only on those readings. "
"Your answer appears on a tiny 16x2 screen: one to three short sentences, at most 160 characters, "
"plain English letters, numbers and simple punctuation only, no emoji. Never say which AI you are.";
const int TASKS = 4;
const char* TASK_NAME[TASKS] = {"Weather report", "Tiny poem", "Comfort tip", "Science fact"};
const char* TASK_TEXT[TASKS] = {
"Write a cheerful indoor weather report, like a TV weather presenter.",
"Write a tiny poem about this room right now.",
"Give one tip to make this room more comfortable.",
"Tell one true science fact linked to this temperature or humidity."};
const int X_PIN = 34, Y_PIN = 35, SW_PIN = 32, DHT_PIN = 27;
LiquidCrystal lcd(19, 23, 18, 17, 16, 4); // RS, E, D4, D5, D6, D7
DHT dht(DHT_PIN, DHT11);
Preferences prefs;
enum Mode { CHOOSE, SHOW, REVEAL };
Mode mode = CHOOSE;
int task = 0, shown = 0, gptVotes = 0, claudeVotes = 0;
String answers[2]; // answer A and answer B
bool claudeIsA = false, seen[2];
bool centred = true;
unsigned long revealUntil = 0;
String pageText = "";
int pageStart = 0;
unsigned long nextPage = 0;
// Only plain letters fit on the LCD: swap curly quotes and long dashes for plain ones,
// and replace any other symbol with one space
String plain(const String& s) {
String out = "";
for (unsigned int i = 0; i < s.length(); i++) {
unsigned char ch = s[i];
if (ch >= 32 && ch < 127) out += (char)ch;
else if (ch == 0xE2 && i + 2 < s.length() && (unsigned char)s[i + 1] == 0x80) {
unsigned char c = s[i + 2];
out += (c == 0x98 || c == 0x99) ? '\'' : (c == 0x9C || c == 0x9D) ? '"' : (c == 0x93 || c == 0x94) ? '-' : ' ';
i += 2;
}
else if (ch < 0x80 || ch >= 0xC0) out += ' '; // the extra bytes of a symbol (0x80-0xBF) are skipped
}
return out;
}
// One LCD line of up to 16 characters starting at 'from', breaking at a space
int fitLine(const String& s, int from, String& line) {
int end = min((int)s.length(), from + 16);
if (end < (int)s.length()) {
int space = s.lastIndexOf(' ', end);
if (space > from) end = space;
}
line = s.substring(from, end);
while (end < (int)s.length() && s[end] == ' ') end++;
return end;
}
void showPage() {
String one, two;
int next = fitLine(pageText, pageStart, one);
int after = fitLine(pageText, next, two);
lcd.clear();
lcd.print(one);
lcd.setCursor(0, 1);
lcd.print(two);
pageStart = after >= (int)pageText.length() ? 0 : after; // start again at the end
}
void showOnLcd(const String& text) {
pageText = plain(text);
pageStart = 0;
nextPage = 0;
}
void turnPages() {
if (pageText.length() == 0 || millis() < nextPage) return;
nextPage = millis() + 3500;
showPage();
}
bool aiOk = false; // did the last question get a real answer?
String errorText(const char* who, int status) {
if (status == 401) return "Please check my API key.";
if (status == 429) return "Too many questions, or no credit left. Wait a minute.";
if (status == 503 || status == 529) return String(who) + " is busy. Try again in a minute.";
return String("Sorry, I could not reach ") + who + ".";
}
// Ask ChatGPT through OpenAI's Responses API
String askChatGPT(const char* system, const String& question) {
aiOk = false;
JsonDocument request;
request["model"] = OPENAI_MODEL;
request["instructions"] = system; // the system prompt
request["input"] = question; // one question from the user
request["max_output_tokens"] = 300; // short answers only
request["reasoning"]["effort"] = "none"; // answer straight away, no thinking
request["store"] = false; // don't keep the conversation on the server
String body;
serializeJson(request, body);
WiFiClientSecure client;
client.setInsecure();
HTTPClient http;
http.setTimeout(30000);
http.begin(client, "https://api.openai.com/v1/responses");
http.addHeader("Content-Type", "application/json");
http.addHeader("Authorization", String("Bearer ") + OPENAI_KEY);
int status = http.POST(body);
String response = http.getString();
http.end();
if (status != 200) {
Serial.printf("ChatGPT error %d: %s\n", status, response.c_str());
return errorText("ChatGPT", status);
}
JsonDocument filter; // keep only what we need from the answer
filter["output"][0]["type"] = true;
filter["output"][0]["content"][0]["type"] = true;
filter["output"][0]["content"][0]["text"] = true;
JsonDocument answer;
if (deserializeJson(answer, response, DeserializationOption::Filter(filter))) return "Sorry, I got a strange answer.";
String text = "";
for (JsonObject item : answer["output"].as<JsonArray>()) {
if (item["type"] != "message") continue; // skip other kinds of item
for (JsonObject part : item["content"].as<JsonArray>()) {
if (part["type"] == "refusal") return "Sorry, I can't help with that one.";
if (part["type"] == "output_text") text += part["text"].as<String>();
}
}
text.trim();
aiOk = text.length() > 0;
return aiOk ? text : "Sorry, I got an empty answer.";
}
// Ask Claude through Anthropic's Messages API
String askClaude(const char* system, const String& question) {
aiOk = false;
JsonDocument request;
request["model"] = CLAUDE_MODEL;
request["max_tokens"] = 2000; // room for its thinking and a short answer
request["output_config"]["effort"] = "low"; // quick, simple answers
request["fallbacks"] = "default"; // if this model declines, try another one
request["system"] = system; // the system prompt
JsonObject m = request["messages"].to<JsonArray>().add<JsonObject>();
m["role"] = "user"; // one question from the user
m["content"] = question;
String body;
serializeJson(request, body);
WiFiClientSecure client;
client.setInsecure();
HTTPClient http;
http.setTimeout(60000); // thinking can take a few seconds
http.begin(client, "https://api.anthropic.com/v1/messages");
http.addHeader("content-type", "application/json");
http.addHeader("x-api-key", CLAUDE_KEY);
http.addHeader("anthropic-version", "2023-06-01");
http.addHeader("anthropic-beta", "server-side-fallback-2026-07-01");
int status = http.POST(body);
String response = http.getString();
http.end();
if (status != 200) {
Serial.printf("Claude error %d: %s\n", status, response.c_str());
return errorText("Claude", status);
}
JsonDocument filter;
filter["stop_reason"] = true;
filter["content"][0]["type"] = true;
filter["content"][0]["text"] = true;
JsonDocument answer;
if (deserializeJson(answer, response, DeserializationOption::Filter(filter))) return "Sorry, I got a strange answer.";
if (answer["stop_reason"] == "refusal") return "Sorry, I can't help with that one.";
String text = "";
for (JsonObject block : answer["content"].as<JsonArray>()) {
if (block["type"] == "text") text += block["text"].as<String>(); // skip the other kinds of block
}
text.trim();
aiOk = text.length() > 0;
return aiOk ? text : "Sorry, I got an empty answer.";
}
// The joystick: 'L', 'R', 'U' or 'D' once per push, 0 when it is in the middle
char joystick() {
int x = analogRead(X_PIN), y = analogRead(Y_PIN);
char dir = 0;
if (x < 800) dir = 'L';
else if (x > 3300) dir = 'R';
else if (y < 800) dir = 'U';
else if (y > 3300) dir = 'D';
if (dir == 0) { centred = true; return 0; }
if (!centred) return 0; // still held from last time
centred = false;
return dir;
}
bool pressed() {
if (digitalRead(SW_PIN) == HIGH) return false;
delay(30);
while (digitalRead(SW_PIN) == LOW) delay(10); // wait until it is let go
return true;
}
void showChoose() {
mode = CHOOSE;
pageText = "";
lcd.clear();
lcd.print(TASK_NAME[task]);
lcd.setCursor(0, 1);
lcd.print("Press = ask both");
}
void showAnswer(int which) {
shown = which;
seen[which] = true;
showOnLcd(String(which == 0 ? "A) " : "B) ") + answers[which] + " [left A, right B, press to vote]");
}
void runTest() {
float t = dht.readTemperature(), h = dht.readHumidity();
if (isnan(t) || isnan(h)) {
lcd.clear();
lcd.print("No DHT11 reading");
delay(2000);
showChoose();
return;
}
String question = "Readings: temperature " + String(t, 1) + " C, relative humidity " + String(h, 0) + " %. Task: " + TASK_TEXT[task];
Serial.println(question);
lcd.clear();
lcd.print("Asking both AIs");
lcd.setCursor(0, 1);
lcd.print("Please wait...");
String gpt = askChatGPT(SYSTEM, question);
bool gptOk = aiOk;
String claude = askClaude(SYSTEM, question);
bool claudeOk = aiOk;
if (!gptOk || !claudeOk) { // a test needs two real answers
showOnLcd(String(gptOk ? "Claude: " : "ChatGPT: ") + (gptOk ? claude : gpt));
unsigned long until = millis() + 8000;
while (millis() < until) { turnPages(); delay(20); }
showChoose();
return;
}
claudeIsA = random(2) == 1; // toss a coin: who is A?
answers[0] = claudeIsA ? claude : gpt;
answers[1] = claudeIsA ? gpt : claude;
Serial.println("A) " + answers[0]);
Serial.println("B) " + answers[1]);
seen[0] = seen[1] = false;
mode = SHOW;
showAnswer(0);
}
void vote() {
if (!seen[0] || !seen[1]) { // be fair: read both first
lcd.clear();
lcd.print("Read A and B");
lcd.setCursor(0, 1);
lcd.print("first!");
delay(1500);
showAnswer(shown);
return;
}
bool forClaude = (shown == 0) == claudeIsA;
if (forClaude) claudeVotes++; else gptVotes++;
prefs.putInt("gpt", gptVotes);
prefs.putInt("claude", claudeVotes);
Serial.printf("You voted %c. A was %s, B was %s. Score: ChatGPT %d, Claude %d\n", shown == 0 ? 'A' : 'B',
claudeIsA ? "Claude" : "ChatGPT", claudeIsA ? "ChatGPT" : "Claude", gptVotes, claudeVotes);
pageText = "";
lcd.clear();
lcd.print(String(shown == 0 ? "A" : "B") + " was " + (forClaude ? "Claude!" : "ChatGPT!"));
lcd.setCursor(0, 1);
lcd.print("GPT " + String(gptVotes));
lcd.setCursor(8, 1);
lcd.print("Cl " + String(claudeVotes)); // Cl = Claude
mode = REVEAL;
revealUntil = millis() + 6000;
}
void setup() {
Serial.begin(115200);
pinMode(SW_PIN, INPUT_PULLUP);
dht.begin();
lcd.begin(16, 2);
prefs.begin("blindtest");
if (digitalRead(SW_PIN) == LOW) { // held at start: new score
prefs.clear();
lcd.print("Score reset");
delay(1500);
while (digitalRead(SW_PIN) == LOW) delay(10); // wait until it is let go
}
gptVotes = prefs.getInt("gpt", 0);
claudeVotes = prefs.getInt("claude", 0);
lcd.clear();
lcd.print("Connecting...");
WiFi.begin(WIFI_NAME, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) delay(500);
Serial.printf("Score so far: ChatGPT %d, Claude %d\n", gptVotes, claudeVotes);
showChoose();
}
void loop() {
char dir = joystick();
if (mode == CHOOSE) {
if (dir == 'U') { task = (task + TASKS - 1) % TASKS; showChoose(); }
if (dir == 'D') { task = (task + 1) % TASKS; showChoose(); }
if (pressed()) runTest();
} else if (mode == SHOW) {
if (dir == 'L') showAnswer(0);
if (dir == 'R') showAnswer(1);
if (pressed()) vote();
turnPages();
} else if (millis() > revealUntil) {
showChoose();
}
delay(20);
}
client.setInsecure() keeps the connection encrypted but skips checking the server's certificate. That keeps the sketch short and is fine for a learning project. For a device you leave running for years, load the server's root certificate with client.setCACert() instead.
Run the test
- Upload the sketch. The LCD shows the first task,
Weather report, andPress = ask both. Push the joystick up or down to choose another task. - Press the joystick button. The LCD says "Asking both AIs" for a few seconds, then shows answer A, two lines at a time.
- Push the joystick right to read answer B, and left to go back to A. If up and down are the wrong way round on your joystick, swap
'U'and'D'injoystick(); if left and right are, swap'L'and'R'. - While the answer you like is on the screen, press the button to vote. The LCD reveals who wrote it, such as
A was Claude!, and the score, such asGPT 3 Cl 4(Cl is Claude). - Let other people vote too, without telling them what to choose. The Serial Monitor at 115200 baud shows every question, both answers and each result.
What you should see

How it works

Both AIs are large language models that run in big data centres. The ESP32 only sends them text and gets text back. askChatGPT() and askClaude() do the same job, but each company's API wants the request in its own shape:
| ChatGPT (OpenAI Responses API) | Claude (Anthropic Messages API) | |
|---|---|---|
| Address | api.openai.com/v1/responses |
api.anthropic.com/v1/messages |
| Key goes in | Authorization: Bearer sk-... |
x-api-key: sk-ant-... |
| System prompt | instructions |
system |
| The question | input |
messages, with the role user
|
| Length and speed |
max_output_tokens: 300, reasoning.effort: "none"
|
max_tokens: 2000, effort: "low"
|
| The answer is in |
output → message → output_text
|
content → text blocks |
The ArduinoJson library builds the request and reads the answer. The answer contains much more than the text, so a filter keeps only the parts the sketch needs and saves memory. If an AI refuses a question, both functions say so politely, and if something goes wrong the Serial Monitor shows the error the API sent back.
Making it fair. Both AIs get exactly the same system prompt, the same readings, the same task and the same 160-character limit. The API settings are not quite identical: Claude is allowed to think a little before it answers, ChatGPT is not. The system prompt also says "Never say which AI you are", or an answer could give itself away. random(2) tosses a coin to decide whether Claude's answer is A or B, so you can't learn that "A is always ChatGPT". You can only vote after reading both answers.
Is the result true? One vote proves almost nothing: answers change every time, and a poem can be lucky. The score only starts to mean something after many votes, by many people, on many tasks. Even then it shows which AI your group liked for these tasks, not which is "best" for everything. And remember that AIs can state wrong facts very confidently: check a science fact in a book or on a trusted website before you believe it. Also note that the two models here have different prices: gpt-6-luna is a small, fast, low-cost model, claude-opus-5-5 is one of Anthropic's most capable ones. Try other models from each company's models page and test again.
The joystick gives two analog readings from 0 to 4095, about 1900 in the middle. The sketch counts a push only when the stick goes from the middle to an edge, so holding it doesn't keep switching. Preferences saves the two vote counts in the ESP32's flash memory, so they survive a reset or a power cut.
Try this next
- Add your own task to
TASK_NAMEandTASK_TEXT(and changeTASKS), such as a joke or a riddle. - Keep a separate score for each task: maybe one AI wins at poems and the other at science facts.
- Add a third choice, "both good", by pushing the joystick down while an answer is shown.
Troubleshooting
-
"Please check my API key": copy the key again. The OpenAI key starts with
sk-, the Claude key withsk-ant-; make sure it is in the right line and has no spaces. - "Too many questions, or no credit left": wait a minute, and check that the account has credit and the monthly limit isn't reached.
-
"Sorry, I could not reach…": open the Serial Monitor at 115200 baud: it prints the error the API sent back. A message about the model means the model name has changed: look up a current one on the company's models page and put it in
OPENAI_MODELorCLAUDE_MODEL. -
The task changes by itself, or A and B flip on their own: check that the joystick's +5V pin goes to 3V3 and GND to GND. Some joysticks rest far from the middle: print
analogRead(34)andanalogRead(35)and adjust 800 and 3300. - Pressing does nothing: SW must go to GPIO 32. On some modules you press the stick straight down quite firmly.
- "No DHT11 reading": check that S goes to the right GPIO and + to 3V3, not 5V.
- The LCD lights up but shows nothing, or only black boxes: turn the potentiometer slowly until the text appears.
- 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.