Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124

The LilyGo T-Display S3 TOUCH is a fascinating little development board that combines an ESP32-S3 microcontroller with a bright TFT display in a very compact format. After spending some time with it I was surprised by just how capable it is. In this post I’ll take you through the hardware details, the setup process, the quirks of programming it, and finally how I used it to create a touchscreen Wordle clone.
At first glance, the board doesn’t look like a typical ESP32 breakout. The touchscreen version comes with a 1.9-inch color display that is framed with a glossy black shroud, plus a button on the front that gives it a slight resemblance to an old iPhone. It feels like a finished product rather than a bare PCB.
The display runs at 170 × 320 resolution, which is more than enough for crisp text and graphics at this size. Two general-purpose buttons sit on the side of the board, along with a recessed reset button. Power and programming are handled over USB-C, which is far more convenient than the old micro-USB connectors still found on many low-cost boards.
On the back you’ll find the brains of the unit: an ESP32-S3 dual-core processor, 16 MB of flash memory, and 8 MB of PSRAM. There’s also a connector for a battery with an onboard charging controller, and an I/O pin that lets you monitor the battery voltage from within your code. Wi-Fi is supported through an internal antenna but there’s also the option of using the external antenna connector, and despite some GPIOs being reserved for the display and touch functions, there are still around eight or nine spare I/O pins, including ADC inputs. That’s plenty for sensors, buttons, or small peripherals in most projects. LilyGo provides a schematic on their website that’s worth a look if you plan to use those pins.

Since the board looks and feels like a mini device already, I decided to give it a proper case before doing any real testing. I found a model on Thingiverse and printed it on a Bamboo Labs 3D printer. From upload to removal, it took only about half an hour, using around 11 grams of filament.
The design is well thought out: the screen slides neatly into the shell, with thin plastic “bars” that line up with the onboard buttons. These transfer the click nicely and make the device easier to handle. The reset button remains accessible, the USB-C port is open, and once the two halves of the case snap together the unit feels much sturdier. For anyone planning to carry the board around or use it regularly, a printed case is a must.

Unlike a plain ESP32 dev kit, the T-Display S3 requires some extra setup. LilyGo maintains a GitHub repository, but instead of just grabbing a library via the Arduino Library Manager, you need to download a large zip file, nearly 300 MB. Inside are datasheets, drawings, and all the supporting files.
The critical step is to find the TFT_eSPI folder inside the lib directory and copy it into your Arduino libraries folder. Without this, the examples won’t compile. A video guide from Volos Projects was invaluable here, as it lays out the exact steps.
Once the library is installed, the board can be programmed via the Arduino IDE like any other ESP32. The USB-C port handles flashing, and after the drivers are set up the process is familiar.
To verify the display and touchscreen, I uploaded a basic test sketch. It simply placed a yellow dot on the screen with the word “target” above it, and displayed touch coordinates whenever I tapped.
#include <Arduino.h>
#include <SPI.h>
#include <Wire.h>
#include <TFT_eSPI.h>
#include <TouchDrvCSTXXX.hpp>
// --- Pins (match LilyGO example) ---
#define PIN_LCD_BL 38
#define PIN_POWER_ON 15
#define BOARD_I2C_SCL 17
#define BOARD_I2C_SDA 18
#define BOARD_TOUCH_IRQ 16
#define BOARD_TOUCH_RST 21
#ifndef CST328_SLAVE_ADDRESS
#define CST328_SLAVE_ADDRESS 0x1A
#endif
#ifndef CST816_SLAVE_ADDRESS
#define CST816_SLAVE_ADDRESS 0x15
#endif
// --- Screen / layout (portrait) ---
TFT_eSPI tft;
TouchDrvCSTXXX touch;
const int SCREEN_W = 170;
const int SCREEN_H = 320;
const int PAD = 6;
// Reset button area
const int BTN_PANEL_H = 46; // reserved area height at the bottom
const int BTN_W = 120;
const int BTN_H = 32;
const int BTN_X = (SCREEN_W - BTN_W) / 2;
const int BTN_Y = SCREEN_H - BTN_PANEL_H + (BTN_PANEL_H - BTN_H) / 2;
// Text area positions
const int TEXT_X = PAD;
const int TEXT_Y1 = 12; // target text y
const int TEXT_Y2 = 30; // touch text y
const int TEXT_Y3 = 48; // error text y
// Target dot (yellow)
const uint16_t COL_BG = TFT_BLACK;
const uint16_t COL_TEXT = TFT_WHITE;
const uint16_t COL_TARGET = TFT_YELLOW;
const uint16_t COL_TOUCH = TFT_CYAN;
const uint16_t COL_PANEL = 0x2124; // dark gray
const uint16_t COL_EDGE = 0x39E7; // light frame
const uint16_t COL_SHADOW = TFT_BLACK;
const int DOT_R = 5;
// --- Touch calibration (your measured portrait values) ---
#define RAW_X_MIN 10
#define RAW_X_MAX 156
#define RAW_Y_MIN 11
#define RAW_Y_MAX 318
// --- Debounce (edge-triggered + guard interval) ---
static bool g_touchDown = false;
static uint32_t g_lastTapMs = 0;
const uint32_t TAP_GUARD_MS = 120;
// --- State ---
int16_t txBuf[5], tyBuf[5];
int targetX = 0, targetY = 0; // where the yellow dot is
bool haveTouch = false; // whether we have a last touch to display
int lastTouchX = 0, lastTouchY = 0;
float lastError = 0.0f;
// --- Helpers ---
static inline int mapClamp(int v, int in_min, int in_max, int out_min, int out_max){
if (v < in_min) v = in_min; if (v > in_max) v = in_max;
long num = (long)(v - in_min) * (out_max - out_min);
long den = (long)(in_max - in_min);
int out = out_min + (int)(num / den);
if (out < out_min) out = out_min; if (out > out_max) out = out_max;
return out;
}
static inline float distPix(int x1,int y1,int x2,int y2){
int dx = x1 - x2, dy = y1 - y2;
return sqrtf((float)(dx*dx + dy*dy));
}
void drawButtonPanel(){
int px = 2;
int py = SCREEN_H - BTN_PANEL_H;
int pw = SCREEN_W - 4;
int ph = BTN_PANEL_H - 2;
// shadow + panel + frames
tft.fillRoundRect(px+2, py+3, pw, ph, 10, COL_SHADOW);
tft.fillRoundRect(px, py, pw, ph, 10, COL_PANEL);
tft.drawRoundRect(px, py, pw, ph, 10, COL_EDGE);
// button
tft.fillRoundRect(BTN_X, BTN_Y, BTN_W, BTN_H, 8, 0x5AEB); // bluish
tft.drawRoundRect(BTN_X, BTN_Y, BTN_W, BTN_H, 8, COL_EDGE);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(COL_TEXT, 0x5AEB);
tft.drawString("RESET", BTN_X + BTN_W/2, BTN_Y + BTN_H/2 + 1, 2);
}
void drawTarget(){
// Yellow dot
tft.fillCircle(targetX, targetY, DOT_R, COL_TARGET);
tft.drawCircle(targetX, targetY, DOT_R, TFT_DARKGREY);
// Target text
tft.setTextDatum(TL_DATUM);
tft.setTextColor(COL_TEXT, COL_BG);
tft.drawString("Target: (" + String(targetX) + "," + String(targetY) + ")", TEXT_X, TEXT_Y1, 2);
}
void clearInfoText(){
// Clear lines where we print info
tft.fillRect(0, TEXT_Y1-2, SCREEN_W, 48, COL_BG);
}
void drawTouchInfo(){
// Touch dot (cyan)
tft.fillCircle(lastTouchX, lastTouchY, DOT_R-2, COL_TOUCH);
tft.drawCircle(lastTouchX, lastTouchY, DOT_R-2, TFT_DARKGREY);
// Text lines
tft.setTextDatum(TL_DATUM);
tft.setTextColor(COL_TEXT, COL_BG);
tft.drawString("Target: (" + String(targetX) + "," + String(targetY) + ")", TEXT_X, TEXT_Y1, 2);
tft.drawString("Touch : (" + String(lastTouchX) + "," + String(lastTouchY) + ")", TEXT_X, TEXT_Y2, 2);
tft.drawString("Error : " + String(lastError, 1) + " px", TEXT_X, TEXT_Y3, 2);
}
void placeNewTarget(){
// Avoid the bottom panel: keep at least 10px margin and above panel
const int margin = 10;
int minY = margin;
int maxY = SCREEN_H - BTN_PANEL_H - margin;
// seed randomness
uint32_t r = esp_random();
targetX = margin + (r % (SCREEN_W - 2*margin));
targetY = minY + ((r >> 10) % (maxY - minY));
haveTouch = false;
// Redraw everything
tft.fillScreen(COL_BG);
drawTarget();
drawButtonPanel();
// Helpful hint
tft.setTextDatum(TL_DATUM);
tft.setTextColor(0xC618, COL_BG); // light gray
tft.drawString("Tap near the yellow dot.", TEXT_X, TEXT_Y3 + 22, 2);
}
void handleTouch(){
uint8_t points = touch.getPoint(txBuf, tyBuf, touch.getSupportTouchPoint());
uint32_t now = millis();
if (points > 0) {
if (!g_touchDown && (now - g_lastTapMs >= TAP_GUARD_MS)) {
g_touchDown = true;
g_lastTapMs = now;
int16_t rx = txBuf[0];
int16_t ry = tyBuf[0];
// Map raw -> portrait pixels
int sx = mapClamp(rx, RAW_X_MIN, RAW_X_MAX, 0, SCREEN_W-1);
int sy = mapClamp(ry, RAW_Y_MIN, RAW_Y_MAX, 0, SCREEN_H-1);
// Button hit?
if (sx >= BTN_X && sx < BTN_X + BTN_W && sy >= BTN_Y && sy < BTN_Y + BTN_H) {
// Button flash
tft.drawRoundRect(BTN_X, BTN_Y, BTN_W, BTN_H, 8, TFT_WHITE);
delay(40);
tft.drawRoundRect(BTN_X, BTN_Y, BTN_W, BTN_H, 8, COL_EDGE);
placeNewTarget();
return;
}
// Register touch; redraw info
lastTouchX = sx;
lastTouchY = sy;
lastError = distPix(targetX, targetY, lastTouchX, lastTouchY);
// Clear any prior info block and re-draw
clearInfoText();
drawTarget(); // redraw target line too (so it's always present)
drawTouchInfo(); // draw touch + text
drawButtonPanel(); // keep button visible / on top
}
} else {
if (g_touchDown) {
g_touchDown = false;
}
}
}
void setup() {
Serial.begin(115200);
pinMode(PIN_POWER_ON, OUTPUT);
digitalWrite(PIN_POWER_ON, HIGH);
pinMode(PIN_LCD_BL, OUTPUT);
digitalWrite(PIN_LCD_BL, HIGH);
tft.init();
tft.setRotation(0); // portrait
tft.fillScreen(COL_BG);
// Touch init (same pattern as LilyGO example)
touch.setPins(BOARD_TOUCH_RST, BOARD_TOUCH_IRQ);
if (!touch.begin(Wire, CST328_SLAVE_ADDRESS, BOARD_I2C_SDA, BOARD_I2C_SCL)) {
Serial.println("CST328 not found, trying CST816...");
if (!touch.begin(Wire, CST816_SLAVE_ADDRESS, BOARD_I2C_SDA, BOARD_I2C_SCL)) {
Serial.println("No touch device found.");
} else {
Serial.println("CST816 ready.");
}
} else {
Serial.println("CST328 ready.");
}
touch.disableAutoSleep(); // we're polling
placeNewTarget();
}
void loop() {
handleTouch();
delay(5);
}
The touchscreen is capacitive, so it responds to light touches without pressure. Accuracy was within about five pixels, impressive given the small 170 × 320 display. For a virtual keyboard with relatively small keys, this accuracy is more than good enough.

The only downside noticed early on was power draw. At around 600 mW in use, the board will drain a small battery fairly quickly. It’s not outrageous, but it’s something to keep in mind if you’re thinking of making a portable project.
With the basics proven, I wanted to try something more ambitious, a handheld version of Wordle. The small touchscreen and sharp display made it a perfect candidate.
The first hurdle was touchscreen calibration. A four-corner calibration showed that the very edges of the screen aren’t fully reachable (coordinates like 5,165 or 5,318 aren’t precise). That’s not a problem once you know about it, but it’s something to account for in your UI design.
Touch sensitivity also needed adjustment. Taps were sometimes picked up multiple times in quick succession. The fix was to implement a debounce delay of about 140 ms, similar to how you would debounce a mechanical button. This cleaned up the input nicely.
Designing the on-screen keyboard took some trial and error. Initially I tried six keys per row, then five, before settling on a layout that balanced readability with usability. On a 1.9-inch screen, key size matters, and getting a workable layout was critical.
For the word list, I used a dataset of about 15,000 five-letter words from GitHub. To be honest, I copied the entire list directly into the Arduino sketch, which makes the code practically unreadable. It’s not elegant, but it works. If you’re making your own Wordle clone then I don’t recommend the word list I used, something like the one linked here is better and has far fewer ‘weird’ words that nobody can ever guess or even knows, plus its only about 3000 in length, rather than 15,000!
The gameplay mimics Wordle exactly:
Below I’ve provided the Wordle clone code, just be aware that I haven’t included the massssssive word list, you simply need to copy your own (lowercase) word list in the place marked with R”~~~( and )~~~”;
#include <Arduino.h>
#include <SPI.h>
#include <Wire.h>
#include <TFT_eSPI.h>
#include <pgmspace.h>
#include <vector>
#include <esp_system.h>
#include <TouchDrvCSTXXX.hpp>
#include <string.h>
#include "pin_config.h"
// ---------- Pins (as per LilyGO example) ----------
#define PIN_LCD_BL 38
#define PIN_POWER_ON 15
#define BOARD_I2C_SCL 17
#define BOARD_I2C_SDA 18
#define BOARD_TOUCH_IRQ 16
#define BOARD_TOUCH_RST 21
#ifndef CST328_SLAVE_ADDRESS
#define CST328_SLAVE_ADDRESS 0x1A
#endif
#ifndef CST816_SLAVE_ADDRESS
#define CST816_SLAVE_ADDRESS 0x15
#endif
// ---------- Display ----------
TFT_eSPI tft;
// ---------- Touch ----------
TouchDrvCSTXXX touch;
int16_t txBuf[5], tyBuf[5];
// ---------- Screen (portrait) ----------
const int SCREEN_W = 170;
const int SCREEN_H = 320;
#define RAW_X_MIN 5
#define RAW_X_MAX 165
#define RAW_Y_MIN 5
#define RAW_Y_MAX 318
static bool g_touchDown = false; // true while finger is down
static uint32_t g_lastTapMs = 0; // last time we accepted a tap
const uint32_t TAP_GUARD_MS = 140; // minimum gap between taps
// ---------- Colors ----------
#define COL_BG TFT_BLACK
#define COL_TEXT TFT_WHITE
#define COL_ACCENT 0x7BEF
#define COL_EMPTY 0x4228
#define COL_ABSENT 0x8410
#define COL_PRESENT 0xFE60
#define COL_CORRECT 0x07E0
#define COL_SELECT 0xF800
#define COL_FLASH 0xFFFF
#define COL_PANEL 0x2124
#define COL_PANEL_EDGE 0x39E7
#define COL_SHADOW 0x0000
#define COL_KEY_BEVEL_TOP 0xC618
#define COL_KEY_BEVEL_BOTTOM 0x3186
// ---------- Layout (portrait) ----------
const int COLS = 5;
const int ROWS = 5;
const int TILE = 24;
const int GAP = 4;
const int GRID_W = COLS*TILE + (COLS-1)*GAP; // 136
const int GRID_H = ROWS*TILE + (ROWS-1)*GAP; // 136
const int GRID_X = (SCREEN_W - GRID_W)/2;
const int GRID_Y = 6;
// Keyboard area
const int KB_TOP = GRID_Y + GRID_H + 12;
const int KB_LEFT = 6;
const int KB_RIGHT = SCREEN_W - 6;
const int KB_W = KB_RIGHT - KB_LEFT;
const int KB_GAP = 5;
int KB_ROW_H = 26;
int KEY_W = 20;
int CTRL_W = 28;
// Rows: 6,6,6,6, then N M ENT DEL (ENT/DEL wider)
const char* R1[] = {"Q","W","E","R","T","Y"};
const char* R2[] = {"U","I","O","P","A","S"};
const char* R3[] = {"D","F","G","H","J","K"};
const char* R4[] = {"L","Z","X","C","V","B"};
const char* R5[] = {"N","M","ENT","DEL"};
const int N1=6, N2=6, N3=6, N4=6, N5=4;
enum class TileState : uint8_t { Empty=0, Absent=1, Present=2, Correct=3 };
struct KeyGeom { int x,y,w,h; String label; TileState st; };
const int MAX_KEYS = (N1+N2+N3+N4+N5);
KeyGeom keys[MAX_KEYS];
int keyCount=0;
// Guess state
int curRow=0, curCol=0;
char guesses[ROWS][COLS+1] = {
{' ',' ',' ',' ',' ', 0},
{' ',' ',' ',' ',' ', 0},
{' ',' ',' ',' ',' ', 0},
{' ',' ',' ',' ',' ', 0},
{' ',' ',' ',' ',' ', 0}
};
TileState gridStates[ROWS][COLS] = {
{TileState::Empty,TileState::Empty,TileState::Empty,TileState::Empty,TileState::Empty},
{TileState::Empty,TileState::Empty,TileState::Empty,TileState::Empty,TileState::Empty},
{TileState::Empty,TileState::Empty,TileState::Empty,TileState::Empty,TileState::Empty},
{TileState::Empty,TileState::Empty,TileState::Empty,TileState::Empty,TileState::Empty},
{TileState::Empty,TileState::Empty,TileState::Empty,TileState::Empty,TileState::Empty}
};
char g_answerLower[6] = "audio";
bool g_gameOver = false;
//
// -------------- PASTE LIST BETWEEN R"~~~( and )~~~": -------------------
const char WORDLIST_BLOB[] PROGMEM = R"~~~(
aahed
aalii
...
zymes
zymic
)~~~";
// -------------- END OF WORDLIST_BLOB PASTE AREA -----------------------------------
const uint32_t WORDLIST_BLOB_SIZE = sizeof(WORDLIST_BLOB) - 1;
std::vector<uint32_t> wl_offsets; // start offsets of 5-letter lines
uint32_t WL_COUNT = 0;
inline char blob_at(uint32_t i){ return (char)pgm_read_byte(&WORDLIST_BLOB[i]); }
void buildWordOffsetsFromBlob() {
wl_offsets.clear();
if (!WORDLIST_BLOB_SIZE) return;
uint32_t i=0; bool atStart=true;
while (i<WORDLIST_BLOB_SIZE) {
char c = blob_at(i);
if (atStart) {
bool ok=true; uint32_t j=i;
for (int k=0;k<5;k++){ if (j>=WORDLIST_BLOB_SIZE){ok=false;break;} char ch=blob_at(j++); if (ch<'a'||ch>'z'){ok=false;break;} }
char next = (j<WORDLIST_BLOB_SIZE) ? blob_at(j) : '\n';
if (ok && (next=='\n' || j==WORDLIST_BLOB_SIZE)) wl_offsets.push_back(i);
atStart=false;
}
if (c=='\n') atStart=true;
i++;
}
WL_COUNT = wl_offsets.size();
}
void getWordLowerAt(uint32_t idx, char out5[6]) { uint32_t off=wl_offsets[idx]; for(int k=0;k<5;k++) out5[k]=blob_at(off+k); out5[5]=0; }
inline void toLower5(char s[6]){ for(int i=0;i<5;i++){ if(s[i]>='A'&&s[i]<='Z') s[i]+=32; } s[5]=0; }
int cmpLowerToBlobWord(uint32_t idx, const char guessLower[6]){
uint32_t off=wl_offsets[idx];
for(int k=0;k<5;k++){ char a=guessLower[k], b=blob_at(off+k); if(a<b) return -1; if(a>b) return 1; }
return 0;
}
bool isValidWordLower(const char guessLower[6]){
if(!WL_COUNT) return false; int32_t lo=0, hi=(int32_t)WL_COUNT-1;
while(lo<=hi){ int32_t mid=(lo+hi)>>1; int cmp=cmpLowerToBlobWord(mid,guessLower); if(!cmp) return true; if(cmp<0) hi=mid-1; else lo=mid+1; }
return false;
}
// ---------- Helpers ----------
inline void setCenter(){ tft.setTextDatum(MC_DATUM); }
uint16_t tileColorFor(TileState s){
switch(s){
case TileState::Empty: return COL_EMPTY;
case TileState::Absent: return COL_ABSENT;
case TileState::Present: return COL_PRESENT;
case TileState::Correct: return COL_CORRECT;
}
return COL_EMPTY;
}
TileState keyboardStateFor(char c){
if(c==' '||c==0) return TileState::Empty;
TileState best=TileState::Empty;
for(int r=0;r<ROWS;r++){
for(int i=0;i<COLS;i++){
if(guesses[r][i]==c){
TileState s=gridStates[r][i];
if(s==TileState::Correct) return TileState::Correct;
if(s==TileState::Present && best!=TileState::Correct) best=TileState::Present;
if(s==TileState::Absent && best==TileState::Empty) best=TileState::Absent;
}
}
}
return best;
}
// ---------- Grid ----------
void drawTile(int x,int y,char c,TileState s){
tft.fillRoundRect(x,y,TILE,TILE,5,COL_EMPTY);
tft.drawRoundRect(x,y,TILE,TILE,5,COL_BG);
if(s!=TileState::Empty){
const int inset=2;
tft.fillRoundRect(x+inset,y+inset,TILE-2*inset,TILE-2*inset,4,tileColorFor(s));
}
if(c!=' '){
tft.setTextColor(COL_TEXT);
setCenter();
tft.drawString(String(c),x+TILE/2,y+TILE/2+1,4);
}
}
void drawGrid(){
for(int r=0;r<ROWS;r++){
for(int c=0;c<COLS;c++){
int x=GRID_X+c*(TILE+GAP);
int y=GRID_Y+r*(TILE+GAP);
drawTile(x,y,guesses[r][c],gridStates[r][c]);
}
}
}
// ---------- Keyboard ----------
void computeKeySizes(){
int n=6;
KEY_W = (KB_W - (n-1)*KB_GAP) / n;
if(KEY_W < 16) KEY_W=16;
KB_ROW_H = 26;
float factor=1.5f;
while(true){
CTRL_W = (int)(factor*KEY_W);
int row5W = KEY_W + KB_GAP + KEY_W + KB_GAP + CTRL_W + KB_GAP + CTRL_W;
if(row5W <= KB_W || factor <= 1.20f) break;
factor -= 0.05f;
}
if(CTRL_W < KEY_W+4) CTRL_W = KEY_W+4;
}
void drawKeyboardPanel(){
int px = 2, py = KB_TOP-6, pw = SCREEN_W-4, ph = SCREEN_H - (KB_TOP-6) - 4;
tft.fillRoundRect(px+2, py+3, pw, ph, 10, COL_SHADOW);
tft.fillRoundRect(px, py, pw, ph, 10, COL_PANEL);
tft.drawRoundRect(px, py, pw, ph, 10, COL_PANEL_EDGE);
tft.drawRoundRect(px+1, py+1, pw-2, ph-2, 9, COL_ACCENT);
}
void drawKey(int x,int y,int w,int h,const String& label, TileState ks){
uint16_t fill = (ks==TileState::Empty)? COL_ACCENT : tileColorFor(ks);
tft.fillRoundRect(x,y,w,h,6,fill);
tft.drawRoundRect(x,y,w,h,6,COL_BG);
if (w > 6) {
tft.drawFastHLine(x+3, y+2, w-6, COL_KEY_BEVEL_TOP);
tft.drawFastHLine(x+3, y+h-3, w-6, COL_KEY_BEVEL_BOTTOM);
}
tft.setTextColor(COL_TEXT, fill);
setCenter();
tft.drawString(label, x + w/2, y + h/2 + 1, 2);
}
void drawRow(const char** labels, int N, int& y){
int totalW=0;
for(int i=0;i<N;i++){ String lbl=labels[i]; bool ctrl=(lbl=="ENT"||lbl=="DEL"); totalW += ctrl?CTRL_W:KEY_W; if(i<N-1) totalW+=KB_GAP; }
int x = KB_LEFT + (KB_W - totalW)/2;
for(int i=0;i<N;i++){
String lbl=labels[i]; bool ctrl=(lbl=="ENT"||lbl=="DEL");
int w = ctrl?CTRL_W:KEY_W;
TileState ks = (lbl.length()==1)? keyboardStateFor(lbl[0]) : TileState::Empty;
drawKey(x,y,w,KB_ROW_H,lbl,ks);
if(keyCount<MAX_KEYS) keys[keyCount++] = {x,y,w,KB_ROW_H,lbl,ks};
x += w + KB_GAP;
}
y += KB_ROW_H + KB_GAP;
}
void drawKeyboard(){
computeKeySizes();
drawKeyboardPanel();
keyCount=0;
int y = KB_TOP + 4;
drawRow(R1,N1,y);
drawRow(R2,N2,y);
drawRow(R3,N3,y);
drawRow(R4,N4,y);
drawRow(R5,N5,y);
}
// ---------- Board ----------
void drawBoard(){
tft.fillScreen(COL_BG);
drawGrid();
drawKeyboard();
}
// ---------- Toast & Reveal ----------
void toast(const String& msg, uint16_t bg=COL_PANEL, uint16_t edge=COL_PANEL_EDGE){
int w=120, h=40;
int x=(SCREEN_W - w)/2;
int y=(KB_TOP - h)/2; // center between top and keyboard
tft.fillRoundRect(x+2,y+3,w,h,8,COL_SHADOW);
tft.fillRoundRect(x,y,w,h,8,bg);
tft.drawRoundRect(x,y,w,h,8,edge);
setCenter();
tft.setTextColor(COL_TEXT, bg);
tft.drawString(msg, x+w/2, y+h/2, 2);
}
void revealAnswerAndRestart(uint32_t ms=5000){
// Build UPPERCASE answer
char ansU[6];
for(int i=0;i<5;i++) ansU[i]=g_answerLower[i]-'a'+'A';
ansU[5]=0;
// Panel a bit larger to show the word bigger
int w=140, h=72;
int x=(SCREEN_W - w)/2;
int y=(KB_TOP - h)/2;
// Draw overlay
tft.fillRoundRect(x+2,y+3,w,h,10,COL_SHADOW);
tft.fillRoundRect(x,y,w,h,10,COL_PANEL);
tft.drawRoundRect(x,y,w,h,10,COL_PANEL_EDGE);
tft.drawRoundRect(x+1,y+1,w-2,h-2,9,COL_ACCENT);
setCenter();
tft.setTextColor(COL_TEXT, COL_PANEL);
tft.drawString("Answer", x+w/2, y+18, 2);
tft.drawString(String(ansU), x+w/2, y+44, 4);
delay(ms);
// Restart
for(int r=0;r<ROWS;r++){
for(int c=0;c<COLS;c++){ guesses[r][c]=' '; gridStates[r][c]=TileState::Empty; }
guesses[r][5]=0;
}
curRow=0; curCol=0; g_gameOver=false;
if (WL_COUNT) {
uint32_t idx = esp_random() % WL_COUNT;
getWordLowerAt(idx, g_answerLower);
}
drawBoard();
}
// ---------- Gameplay ----------
void pickRandomAnswerLower(char out5[6]) {
if (!WL_COUNT) { strncpy(out5,"audio",6); return; }
uint32_t idx = esp_random() % WL_COUNT;
getWordLowerAt(idx, out5);
}
void scoreGuessToRow(int row, const char answerUpper[6], const char guessUpper[6]) {
int freq[26]={0};
for (int i=0;i<5;i++){ int idx=answerUpper[i]-'A'; if(idx>=0&&idx<26) freq[idx]++; }
for (int i=0;i<5;i++){
guesses[row][i]=guessUpper[i];
if (guessUpper[i]==answerUpper[i]){ gridStates[row][i]=TileState::Correct; int idx=guessUpper[i]-'A'; if(idx>=0&&idx<26) freq[idx]--; }
else gridStates[row][i]=TileState::Empty;
}
for (int i=0;i<5;i++){
if (gridStates[row][i]==TileState::Empty){
int idx=guessUpper[i]-'A';
if (idx>=0&&idx<26 && freq[idx]>0){ gridStates[row][i]=TileState::Present; freq[idx]--; }
else { gridStates[row][i]=TileState::Absent; }
}
}
for (int c=0;c<5;c++){
int x=GRID_X+c*(TILE+GAP);
int y=GRID_Y+row*(TILE+GAP);
drawTile(x,y,guesses[row][c],gridStates[row][c]);
}
}
void resetGame(){
for(int r=0;r<ROWS;r++){
for(int c=0;c<COLS;c++){ guesses[r][c]=' '; gridStates[r][c]=TileState::Empty; }
guesses[r][5]=0;
}
curRow=0; curCol=0; g_gameOver=false;
pickRandomAnswerLower(g_answerLower);
drawBoard();
}
// Editing
void addLetter(char c){
if (g_gameOver) return;
if (curCol>=COLS) return;
if (c>='a'&&c<='z') c = c-'a'+'A';
if (c<'A'||c>'Z') return;
guesses[curRow][curCol]=c;
gridStates[curRow][curCol]=TileState::Empty;
int x=GRID_X+curCol*(TILE+GAP);
int y=GRID_Y+curRow*(TILE+GAP);
drawTile(x,y,c,TileState::Empty);
curCol++;
}
void backspace(){
if (g_gameOver) return;
if (curCol<=0) return;
curCol--;
guesses[curRow][curCol]=' ';
gridStates[curRow][curCol]=TileState::Empty;
int x=GRID_X+curCol*(TILE+GAP);
int y=GRID_Y+curRow*(TILE+GAP);
drawTile(x,y,' ',TileState::Empty);
}
void pressEnter(){
if (g_gameOver) return;
if (curCol<COLS) return;
char guessU[6]; for(int i=0;i<5;i++) guessU[i]=guesses[curRow][i]; guessU[5]=0;
char guessL[6]; for(int i=0;i<6;i++) guessL[i]=guessU[i]; toLower5(guessL);
if (!isValidWordLower(guessL)){
for(int t=0;t<2;t++){
for(int c=0;c<5;c++){ int x=GRID_X+c*(TILE+GAP), y=GRID_Y+curRow*(TILE+GAP); tft.drawRoundRect(x,y,TILE,TILE,5,COL_SELECT); }
delay(80);
for(int c=0;c<5;c++){ int x=GRID_X+c*(TILE+GAP), y=GRID_Y+curRow*(TILE+GAP); tft.drawRoundRect(x,y,TILE,TILE,5,COL_BG); }
delay(80);
}
return;
}
char ansU[6]; for(int i=0;i<5;i++) ansU[i]=g_answerLower[i]-'a'+'A'; ansU[5]=0;
scoreGuessToRow(curRow, ansU, guessU);
// Refresh keyboard colors to reflect new info
drawKeyboard();
bool win=true; for (int i=0;i<5;i++) if (gridStates[curRow][i]!=TileState::Correct){ win=false; break; }
if (win){
g_gameOver=true;
for(int k=0;k<2;k++){
for (int c=0;c<5;c++){ int x=GRID_X+c*(TILE+GAP), y=GRID_Y+curRow*(TILE+GAP); tft.drawRoundRect(x,y,TILE,TILE,5,COL_FLASH); }
delay(70);
for (int c=0;c<5;c++){ int x=GRID_X+c*(TILE+GAP), y=GRID_Y+curRow*(TILE+GAP); tft.drawRoundRect(x,y,TILE,TILE,5,COL_BG); }
delay(70);
}
toast("Well done!");
delay(900);
resetGame();
} else if (curRow<ROWS-1){
curRow++; curCol=0;
} else {
g_gameOver=true;
// NEW: show the correct word for 5 seconds, then restart
revealAnswerAndRestart(5000);
}
}
// ---------- Touch mapping ----------
static inline int mapClamp(int v, int in_min, int in_max, int out_min, int out_max){
if (v < in_min) v = in_min; if (v > in_max) v = in_max;
long num = (long)(v - in_min) * (out_max - out_min);
long den = (long)(in_max - in_min);
int out = out_min + (int)(num / den);
if (out < out_min) out = out_min; if (out > out_max) out = out_max;
return out;
}
// Edge-triggered processing: only on the transition from "no touch" -> "touch"
// plus a small guard interval to avoid fast repeats.
void handleTouch(){
uint8_t points = touch.getPoint(txBuf, tyBuf, touch.getSupportTouchPoint());
uint32_t now = millis();
if (points > 0) {
if (!g_touchDown && (now - g_lastTapMs >= TAP_GUARD_MS)) {
g_touchDown = true; // begin touch
g_lastTapMs = now;
// raw portrait coords
int16_t rx = txBuf[0];
int16_t ry = tyBuf[0];
// map raw -> screen pixels using your calibration
int sx = mapClamp(rx, RAW_X_MIN, RAW_X_MAX, 0, SCREEN_W-1);
int sy = mapClamp(ry, RAW_Y_MIN, RAW_Y_MAX, 0, SCREEN_H-1);
// Hit-test keyboard first (primary interaction)
for (int i=0;i<keyCount;i++){
const KeyGeom &k = keys[i];
if (sx >= k.x && sx < (k.x+k.w) && sy >= k.y && sy < (k.y+k.h)) {
// quick visual feedback (outline flash)
tft.drawRoundRect(k.x,k.y,k.w,k.h,6,COL_FLASH);
delay(30);
tft.drawRoundRect(k.x,k.y,k.w,k.h,6,COL_BG);
if (k.label.length()==1) addLetter(k.label[0]);
else if (k.label=="DEL") backspace();
else if (k.label=="ENT") pressEnter();
return;
}
}
// Optional: tap grid to move caret column
if (sy >= GRID_Y && sy < GRID_Y + GRID_H) {
int col = (sx - GRID_X) / (TILE + GAP);
if (col >= 0 && col < COLS) curCol = col;
}
}
} else {
// finger lifted
if (g_touchDown) {
g_touchDown = false;
// leave g_lastTapMs as-is to enforce guard interval
}
}
}
// ---------- Arduino ----------
void setup(){
Serial.begin(115200);
pinMode(PIN_POWER_ON,OUTPUT); digitalWrite(PIN_POWER_ON,HIGH);
pinMode(PIN_LCD_BL,OUTPUT); digitalWrite(PIN_LCD_BL,HIGH);
tft.init();
tft.setRotation(0); // PORTRAIT (170x320)
tft.fillScreen(COL_BG);
// Touch init — same pattern as the working example
touch.setPins(BOARD_TOUCH_RST, BOARD_TOUCH_IRQ);
if (!touch.begin(Wire, CST328_SLAVE_ADDRESS, BOARD_I2C_SDA, BOARD_I2C_SCL)) {
Serial.println("Failed init CST328, trying CST816...");
if (!touch.begin(Wire, CST816_SLAVE_ADDRESS, BOARD_I2C_SDA, BOARD_I2C_SCL)) {
Serial.println("Failed init CST816. Touch disabled.");
} else {
Serial.println("CST816 ready.");
}
} else {
Serial.println("CST328 ready.");
}
touch.disableAutoSleep(); // polling (like example)
buildWordOffsetsFromBlob();
if (WL_COUNT) {
uint32_t idx = esp_random() % WL_COUNT;
getWordLowerAt(idx, g_answerLower);
}
drawBoard();
}
void loop(){
handleTouch();
delay(5);
}
// ---------- TFT Pin check ----------
#if PIN_LCD_WR != TFT_WR || \
PIN_LCD_RD != TFT_RD || \
PIN_LCD_CS != TFT_CS || \
PIN_LCD_DC != TFT_DC || \
PIN_LCD_RES != TFT_RST|| \
PIN_LCD_D0 != TFT_D0 || \
PIN_LCD_D1 != TFT_D1 || \
PIN_LCD_D2 != TFT_D2 || \
PIN_LCD_D3 != TFT_D3 || \
PIN_LCD_D4 != TFT_D4 || \
PIN_LCD_D5 != TFT_D5 || \
PIN_LCD_D6 != TFT_D6 || \
PIN_LCD_D7 != TFT_D7 || \
PIN_LCD_BL != TFT_BL || \
TFT_BACKLIGHT_ON != HIGH || \
170 != TFT_WIDTH || \
320 != TFT_HEIGHT
#error "Select <User_Setups/Setup206_LilyGo_T_Display_S3.h> in <TFT_eSPI/User_Setup_Select.h>"
#endif
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5,0,0)
#error "Use Arduino-ESP32 version below 3.0 for this sketch"
#endif

On the first run I tried the popular opening word, TRACE. Input was smooth, no double taps, no missed letters, which was a pleasant surprise given the tiny keys. The result showed “T” and “C” as the correct letter but in the wrong place. On my second guess I went for “CLOTH” and “C”, “O”, and “T” were in the correct place (green), while “H” is in the word, but in the wrong place.
On my video camera the TFT display looked a little disappointing, but in person it’s crisp and clear, the best small display I’ve seen. The resolution is high enough that both the keyboard and the grid of guesses are easily readable.
The LilyGo T-Display S3 is a fantastic little board. The touchscreen is accurate, the display quality is excellent, and the ESP32-S3 provides serious processing power with Wi-Fi built in. Add in battery support and USB-C programming and you have a self-contained platform for portable projects.
The Wordle clone was just a fun demo, but it shows how practical the touchscreen is for real applications. Whether you want to build custom interfaces, handheld games, or sensor dashboards, this board has the hardware to support it.
I highly recommend picking one up if you’re curious. For the price, it’s hard to beat, and once you’ve gone through the setup process the possibilities are wide open.
👉 Watch the full video demonstration here: Exploring the LilyGo T-Display S3 and Developing a Wordle Clone