ChatGPT vs Claude: run a blind test with a sensor, a joystick and an LCD

Choose a task with the joystick, and the ESP32 gives the same sensor readings and the same task to ChatGPT and to Claude. You read the two answers as A and B without knowing which is which, vote with the...
  • Level:Advanced
  • Duration: 1 h 15 min
  • Ages 12+
  • Platform: ESP32

Tools for this lesson

Helpful for this build. Open a tool to see tips and more projects that use it.

ChatGPT vs Claude: run a blind test with a sensor, a joystick and an LCD – EEMENTOR STEM™ lesson

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

Breadboard wiring diagram: ChatGPT vs Claude: run a blind test with a sensor, a joystick and an LCD
Breadboard view. Check each connection against the wiring table below.

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.

Step 1 of 4: Power rails: 5V and GND from the ESP32
Step 1. Power rails: 5V and GND from the ESP32
Step 2 of 4: 1602 LCD: RS, E and D4–D7 to the ESP32, plus power, contrast knob and backlight
Step 2. 1602 LCD: RS, E and D4–D7 to the ESP32, plus power, contrast knob and backlight
Step 3 of 4: Joystick: VRx → GPIO 34, VRy → 35, SW → 32, GND → − rail, +5V → 3V3 (orange jumper on to the DHT11)
Step 3. Joystick: VRx → GPIO 34, VRy → 35, SW → 32, GND → − rail, +5V → 3V3 (orange jumper on to the DHT11)
Step 4 of 4: DHT11: S → GPIO 27, − → − rail (its + already has 3V3)
Step 4. DHT11: S → GPIO 27, − → − rail (its + already has 3V3)
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.

  1. 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-.
  2. 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-.
  3. 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

  1. Upload the sketch. The LCD shows the first task, Weather report, and Press = ask both. Push the joystick up or down to choose another task.
  2. Press the joystick button. The LCD says "Asking both AIs" for a few seconds, then shows answer A, two lines at a time.
  3. 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' in joystick(); if left and right are, swap 'L' and 'R'.
  4. 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 as GPT 3 Cl 4 (Cl is Claude).
  5. 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

LCD screens Weather report, Asking both AIs, answer A and the reveal A was Claude! with GPT 3 and Cl 4, a joystick guide, and the Serial Monitor with both answers and the vote

How it works

The same question going to ChatGPT and Claude, a random(2) coin toss deciding A and B, then vote, reveal and the score saved in flash with Preferences

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_NAME and TASK_TEXT (and change TASKS), 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 with sk-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_MODEL or CLAUDE_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) and analogRead(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.

Build it yourself

Everything used in this lesson comes in one kit, with parts tested by our engineers.

ESP32 IoT Starter Kit | Arduino IDE Compatible | Free Tutorials $57.00

Shop the kit