This project is a smart e-paper scoreboard built using a 7.5-inch monochrome E-Ink display and the Seeed XIAO ESP32S3 PLUS with the XIAO ePaper Display Driver Board (EE04). The scoreboard is fully controlled over Wi-Fi, allowing users to update team names, scores, and event info from any smartphone or laptop—no app required.
Thanks to the e-paper screen, the display offers excellent visibility, ultra-low power consumption, and remains readable even without backlighting. This makes it ideal for indoor tournaments, school events, maker projects, or classrooms.
After powering on the device, the ESP32S3 automatically starts a Wi-Fi access point and hosts a small web server. You can connect to this network using any device, open the control page in your browser, and update the team names, scores, and event information. When you press the update button, the e-paper display instantly refreshes and shows your new data. Because e-paper technology only consumes power during refresh, the scoreboard can run for long periods using minimal energy.
To make this even simpler, the driver board used here—the Seeed XIAO ePaper EE04—supports both 24-pin and 50-pin displays, so it works with a wide range of E-Ink panels. It also includes a built-in battery charging IC, a power switch, JST battery connectors, and three programmable buttons. This means there is absolutely no soldering required for this build. You simply plug the display into the board, upload the code, and the hardware side is complete
So let's get into the build
Main parts used for this projectXIAO ePaper Display Board(ESP32-S3) - EE04
7.5" Monochrome eInk / ePaper Display with 800x480 Pixels
Step 1: 3D PrintingWe can start with the 3d printing of the enclosure. I printed all of the parts in the White PLA+. you find all the STL files along with the STEP files
Step 2 : Choosing the Display and Preparing the Driver BoardFor this build, a 7.5-inch monochrome e-paper display was selected because it offers excellent visibility and enough screen area to show large score digits.
This particular screen uses a 24-pin ribbon cable, so before connecting anything, the jumper on the EE04 driver board must be moved to the 24-pin position. This ensures the display interfaces correctly with the board
Step 3 — Setting Up the Arduino IDE and Flashing the codeTo program the board, the Seeed GFX library must be installed. This library provides all the low-level functions for handling E-Ink refreshes, drawing text, and managing fonts. After installing it, the next step is to open Seeed’s online configuration tool.
This tool lets you select the exact E-Ink display and the EE04 version of the driver board. Once the correct model is selected, the tool generates the driver code automatically.
Here is the generated Configuration
#define BOARD_SCREEN_COMBO 502 // 7.5 inch monochrome ePaper Screen (UC8179)
#define USE_XIAO_EPAPER_DISPLAY_BOARD_EE04This code needs to be copied into a new tab in the Arduino IDE. After opening your main program file, create a new tab named driver.h, and paste the generated code there. With this in place, the Arduino sketch will know how to communicate with the display.
Finally, connect the XIAO ESP32S3 PLUS to your computer, select it from the Arduino board manager, and upload the program. Once uploaded successfully, the driver board setup is complete.
Here is the completed code
#include <WiFi.h>
#include <WebServer.h>
#define EPAPER_ENABLE
#include "TFT_eSPI.h"
#ifdef EPAPER_ENABLE
EPaper epaper;
#endif
const char* ssid = "Scoreboard_AP";
const char* password = "123456789";
WebServer server(80);
String teamA = "TEAM 1";
String teamB = "TEAM 2";
int scoreA = 0;
int scoreB = 0;
String bottomInfo = "FOOTBALL CHAMPIONSHIP";
IPAddress localIP;
float avgCharWidthForSize(int s) { return 6.0f * s; }
void drawCenterStringInRegion(const String &txt, int x0, int w, int y, int textSize) {
epaper.setTextSize(textSize);
int approxW = max(1, (int)(txt.length() * avgCharWidthForSize(textSize)));
int x = x0 + (w - approxW) / 2;
if (x < x0) x = x0;
epaper.drawString(txt, x, y);
}
void drawCenterString(const String &txt, int y, int textSize) {
drawCenterStringInRegion(txt, 0, epaper.width(), y, textSize);
}
String formatScore(int s) {
if (s < 0) s = 0;
if (s > 99) s = 99;
if (s < 10) return "0" + String(s);
return String(s);
}
void showIPOnBoot() {
#ifdef EPAPER_ENABLE
epaper.begin();
epaper.fillScreen(TFT_WHITE);
epaper.setTextColor(TFT_BLACK, TFT_WHITE);
epaper.setTextSize(3);
String ipText = "IP: " + localIP.toString();
drawCenterString(ipText, epaper.height() / 2, 3);
epaper.update();
delay(5000); // Show IP for 5 seconds
#endif
}
void updateDisplay() {
#ifdef EPAPER_ENABLE
epaper.begin();
epaper.fillScreen(TFT_WHITE);
epaper.setTextColor(TFT_BLACK, TFT_WHITE);
int midX = epaper.width() / 2;
int screenWidth = epaper.width();
int screenHeight = epaper.height();
// Draw separation lines
epaper.drawLine(2, 88, screenWidth - 1, 88, TFT_BLACK); // Horizontal line below team names
epaper.drawLine(midX, 0, midX, 405, TFT_BLACK); // Vertical center line
epaper.drawLine(0, 406, screenWidth, 406, TFT_BLACK); // Horizontal line above bottom info
// Team Names - centered above scores
drawCenterStringInRegion(teamA, 0, midX, 30, 4);
drawCenterStringInRegion(teamB, midX, midX, 30, 4);
// Scores - using large font size as requested
epaper.setTextSize(6);
epaper.setTextFont(7);
epaper.drawString(formatScore(scoreA), 0, 100);
epaper.drawString(formatScore(scoreB), 420, 100);
// Bottom information
epaper.setTextSize(4);
epaper.setTextFont(1);
drawCenterString(bottomInfo, 420, 4);
epaper.update();
#endif
}
String htmlPage() {
String html = R"HTML(
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Scoreboard Control</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: #8fc31f;
margin: 0;
padding: 15px;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
color: #333;
}
.container {
max-width: 700px;
width: 100%;
margin: 0 auto;
}
.header {
text-align: center;
margin-bottom: 20px;
color: white;
}
.header h1 {
font-size: 2rem;
margin-bottom: 5px;
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.header p {
font-size: 1rem;
opacity: 0.9;
}
.card {
background: rgba(255, 255, 255, 0.95);
padding: 20px;
border-radius: 15px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 20px;
}
.form-section {
background: #f8f9fa;
padding: 15px;
border-radius: 10px;
border: 1px solid #e9ecef;
}
.form-section h3 {
color: #495057;
margin-bottom: 12px;
font-size: 1.1rem;
border-bottom: 2px solid #dee2e6;
padding-bottom: 6px;
}
.form-group {
margin-bottom: 12px;
}
label {
display: block;
font-weight: 600;
margin-bottom: 6px;
color: #495057;
font-size: 0.85rem;
}
input {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 2px solid #e9ecef;
font-size: 14px;
transition: all 0.3s ease;
background: white;
}
input:focus {
outline: none;
border-color: #8fc31f;
box-shadow: 0 0 0 3px rgba(143, 195, 31, 0.2);
}
.bottom-info-section {
grid-column: 1 / -1;
background: #f8f9fa;
padding: 15px;
border-radius: 10px;
border: 1px solid #e9ecef;
margin-bottom: 20px;
}
.btn-container {
text-align: center;
}
.btn {
background: #8fc31f;
color: white;
padding: 12px 30px;
border-radius: 8px;
font-weight: bold;
border: none;
cursor: pointer;
font-size: 1rem;
transition: all 0.3s ease;
width: 100%;
}
.btn:hover {
background: #7aad1a;
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
}
.ip-display {
text-align: center;
margin-top: 15px;
color: white;
font-size: 0.9rem;
}
@media (max-width: 768px) {
.form-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.header h1 {
font-size: 1.8rem;
}
.card {
padding: 15px;
}
body {
padding: 10px;
}
}
@media (max-width: 480px) {
.header h1 {
font-size: 1.6rem;
}
input {
padding: 8px 10px;
font-size: 13px;
}
.form-section {
padding: 12px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Scoreboard Control</h1>
<p>Update team information and scores</p>
</div>
<div class="card">
<form method="POST" action="/update">
<div class="form-grid">
<div class="form-section">
<h3>Team A Settings</h3>
<div class="form-group">
<label for="teamA">Team A Name</label>
<input type="text" id="teamA" name="teamA" value=")HTML";
html += teamA;
html += R"HTML(" placeholder="Team A name" maxlength="15">
</div>
<div class="form-group">
<label for="scoreA">Team A Score</label>
<input type="number" id="scoreA" name="scoreA" min="0" max="99" value=")HTML";
html += scoreA;
html += R"HTML(">
</div>
</div>
<div class="form-section">
<h3>Team B Settings</h3>
<div class="form-group">
<label for="teamB">Team B Name</label>
<input type="text" id="teamB" name="teamB" value=")HTML";
html += teamB;
html += R"HTML(" placeholder="Team B name" maxlength="15">
</div>
<div class="form-group">
<label for="scoreB">Team B Score</label>
<input type="number" id="scoreB" name="scoreB" min="0" max="99" value=")HTML";
html += scoreB;
html += R"HTML(">
</div>
</div>
<div class="bottom-info-section">
<h3>Bottom Information</h3>
<div class="form-group">
<label for="bottomInfo">Information Text</label>
<input type="text" id="bottomInfo" name="bottomInfo" value=")HTML";
html += bottomInfo;
html += R"HTML(" placeholder="Bottom information text" maxlength="30">
</div>
</div>
</div>
<div class="btn-container">
<button class="btn" type="submit">
Update Display
</button>
</div>
</form>
</div>
<div class="ip-display">
<p>Connect to: <strong>)HTML";
html += localIP.toString();
html += R"HTML(</strong></p>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Add input validation for scores
const scoreInputs = document.querySelectorAll('input[type="number"]');
scoreInputs.forEach(input => {
input.addEventListener('input', function() {
if (this.value < 0) this.value = 0;
if (this.value > 99) this.value = 99;
});
});
});
</script>
</body>
</html>)HTML";
return html;
}
void handleRoot() {
server.send(200, "text/html", htmlPage());
}
void handleUpdate() {
if (server.hasArg("teamA")) teamA = server.arg("teamA");
if (server.hasArg("teamB")) teamB = server.arg("teamB");
if (server.hasArg("scoreA")) scoreA = server.arg("scoreA").toInt();
if (server.hasArg("scoreB")) scoreB = server.arg("scoreB").toInt();
if (server.hasArg("bottomInfo")) bottomInfo = server.arg("bottomInfo");
updateDisplay();
// Redirect back to the main page
server.sendHeader("Location", "/");
server.send(303, "text/plain", "Updated. Redirecting...");
}
void setup() {
Serial.begin(115200);
// Start WiFi Access Point
WiFi.softAP(ssid, password);
localIP = WiFi.softAPIP();
Serial.println("Access Point Started");
Serial.print("IP Address: ");
Serial.println(localIP);
// Show IP on display for 5 seconds
showIPOnBoot();
// Initialize web server routes
server.on("/", handleRoot);
server.on("/update", HTTP_POST, handleUpdate);
server.begin();
Serial.println("HTTP server started");
// Initial display update
updateDisplay();
}
void loop() {
server.handleClient();
}Step 4 — Assembly1. Place the 7.5-inch eink display on the display frame
2. Close down the display frame with the display holder
3. Secure it with M2 X 7mm screws
4. Put the EE04 driver board on the driver board holder
5. Screw in the driver board and holder frame with a single screw. Try to use an M2 screw shorter than 7mm for this task; otherwise, a longer screw will damage the display.
6. Connect the display cable to the 24-pin FPC connector
7.Connect the battery to the board using the 2mm JST connector. Secure the battery with glue or double-sided tape.
8. Please attach the antenna to the back cover.
9. Connect the antenna to the XIAO.
10. Close down the back cap
11. Finally, attach the stand to the main frame using the M3 10mm screws
So we are done with the assembly
Step 5 : How the Scoreboard Works and How to ConnectOnce the scoreboard is powered on, the ESP32S3 immediately starts running the main program. The first thing it does is create a Wi-Fi access point called “Scoreboard_AP”, secured with the password 123456789. At the same time, the device generates the IP address for its built-in web server and displays that IP on the e-paper screen for about five seconds. This makes it easy to know where to connect.
After showing the IP, the display switches to the default scoreboard layout, while the Wi-Fi server continues running in the background. All the interface code for the webpage is embedded directly inside the Arduino sketch, meaning the ESP32S3 serves a complete control panel without relying on external files. The page includes fields for team names, score values, and the bottom information banner, and it automatically adjusts its layout whether you are using a computer, tablet, or phone.
At this point, the scoreboard is ready for user interaction. Using any device, you simply join the Scoreboard_AP Wi-Fi network, open your browser, and enter the IP address that appeared during boot. This takes you straight to the control page. From here, you can type in new team names, update the scores, or change the event description. When you press “Update Display, ” the ESP32S3 receives your inputs, stores the new values, and refreshes the e-paper display with the updated scoreboard. Because the display only consumes power during the refresh cycle, it can hold the updated image without draining energy, making the system very efficient even on battery power.
With this combined system, the scoreboard becomes fully interactive, easy to use, and accessible from any device—creating a smooth and seamless user experience.
ConclusionThis project demonstrates how easy and practical it is to build Wi-Fi-controlled E-Ink applications using the XIAO ESP32S3 PLUS and the EE04 driver board. The combination of a responsive web interface and ultra-low-power display makes this scoreboard not only functional but alsoefficient and visually appealing. All that’s left is to customise the enclosure, design your scoreboard layout, and enjoy the final result.
If you want the STL files, detailed wiring, or additional improvements, you can find links in the description. Feel free to ask any questions—happy making!







_t9PF3orMPd.png?auto=compress%2Cformat&w=40&h=40&fit=fillmax&bg=fff&dpr=2)







Comments