Community project

Secure Pokémon Card Trading Device

ESP32
Photo of Secure Pokémon Card Trading Device
Generated with AI

gulyasaron2016

Published August 21, 2026

This guide builds a handheld device for secure Pokémon card trading that uses cryptographic authentication and tamper detection to protect valuable cards during peer-to-peer exchanges. The device combines an ESP32 microcontroller, a 3.5-inch color display, secure element chip (ATECC608A), real-time clock, and MicroSD storage in a battery-powered handheld form factor with a removable card carrier.

Builders will receive a complete wiring diagram, parts list, step-by-step assembly instructions for the power system and mechanical housing, and firmware that handles wireless trading negotiation, card authentication, tamper warnings, and access controls. The guide covers preparing the LiPo battery with charging protection, integrating the large TFT display, constructing the card carrier mechanism, and testing all security features before final assembly.

Wiring diagram

Wiring diagram for Secure Pokémon Card Trading Device

Gather all the parts

QtyComponent
1

MicroSD Card Module

PokePlate removable storage

SPI-based microSD card adapter module for SPI-capable microcontrollers. Uses MOSI, MISO, SCK, and CS plus power and ground. Many low-cost modules include a 3.3 V regulator and level shifting for 5 V MCU boards, while bare breakouts should be powered and signalled at 3.3 V.

1

ATECC608A secure-element breakout

PokePlate identity chip

Cryptographic identity chip in the removable PokePlate that proves the collection was issued by this project.

1

LiPo 3.7V 1000mAh Battery

3.7 V 1000 mAh

Single-cell LiPo pack, nominal 3.7 V, 1000 mAh. Default rechargeable choice for portable ESP32 / Pico projects. Pair with a TP4056 charger for safe USB recharging.

1

TP4056 Li-Ion/LiPo charger module with protection

protected 6-pad module

TP4056 single-cell Li-Ion/LiPo linear charger module, 5V USB input, 1A charge current (programmable). Common variants ship with DW01 protection. Pair with battery_lipo_storage for the cell.

1

Boost Converter

set to 5.0 V

Small adjustable MT3608-style DC-DC boost converter module for stepping a lower DC input up to a higher rail such as 5V, 9V, or 12V. It is a power-path module with VIN/VOUT terminals, not a GPIO peripheral.

1

Push Button

Up

Momentary push button switch

1

Push Button

Down

Momentary push button switch

1

Push Button

R Reset

Momentary push button switch

1

Push Button

T Trade

Momentary push button switch

1

Push Button

A Fastkey

Momentary push button switch

1

Push Button

B Fastkey

Momentary push button switch

1

Class-open attempt switch

normally-open case switch

A case-interlock switch that records each attempt to open the handheld during a class session.

1

PokePlate presence switch

normally-open latch detector

A normally-open microswitch that confirms the PokePlate cover and hook are fully latched.

1

Adafruit DS3231 Precision RTC Breakout

with CR1220 backup cell

DS3231 precision real-time clock breakout with integrated TCXO and crystal for accurate timekeeping over I2C.

1

ILI9488 3.5 inch SPI TFT Display

3.5 in 480×320 full-color RGB TFT

3.5 inch 480x320 TFT LCD module using the ILI9488 controller over 4-wire SPI. It uses MOSI, SCK, CS, DC, and RESET for the display, with optional MISO/readback, PWM backlight control, and XPT2046 resistive touch pins on touch variants. Common ESP32 projects use TFT_eSPI with the ILI9488 driver selected.

Assemble it in 7 steps

1. Prepare the battery power section

Mount the protected charger and boost converter away from the soft battery pouch. Connect battery positive to charger B+ (battery power) and battery negative to B- (battery ground). Connect charger OUT+ to boost VIN+ (power) and charger OUT- to boost VIN- (ground). Set the boost output to exactly 5.0 V with a meter before it reaches the main board.

  • Keep the battery in a holder or pocket so it cannot be punctured.
  • Use short, thicker wires for battery and boost connections.
  • Do not reverse the battery wires — reversed battery wiring can overheat or damage the charger.
  • Do not connect the boost output until it measures 5.0 V — a higher voltage can damage the handheld.

2. Fit the larger color screen

Mount the 3.5-inch ILI9488 screen in landscape behind the front window. Connect VCC to 3V3 (power), GND to GND (ground), SCK to GPIO12 (clock), MOSI to GPIO11 (screen data), CS to GPIO10 (screen select), DC to GPIO14 (command/data signal), RESET to GPIO15 (reset signal), and LED to GPIO16 (backlight brightness). Connect boost VOUT+ to the ESP32 5V/VIN input (board power) and boost VOUT- to ESP32 GND (ground).

  • This 480×320 RGB TFT is much larger than the former display, so make the front opening about 85 mm by 56 mm.
  • The display and removable card reader share SCK and MOSI; their separate CS wires tell the board which one is being used.
  • Use the display's 3.3 V supply connection — 5 V on a 3.3 V-only screen can damage it.
  • Make sure VCC and GND are not swapped — swapped power can damage the screen.

3. Build the removable PokePlate carrier

Make one removable carrier that holds the microSD board and secure identity chip. Connect microSD VCC to 3V3 (power), GND to GND (ground), SCK to GPIO12 (clock), MOSI to GPIO11 (data), MISO to GPIO13 (data back), and CS to GPIO17 (card select). Connect ATECC608A VCC to 3V3 (power), GND to GND (ground), SDA to GPIO8 (identity data), and SCL to GPIO9 (identity clock).

  • Use a keyed connector so the carrier cannot be inserted backward.
  • The secure chip and clock share the SDA and SCL wires, which is normal.
  • Do not remove the PokePlate while its collection is being written — that can corrupt its card data.
  • Keep every PokePlate signal at 3.3 V — 5 V can damage the microSD and identity chip.

4. Fit the battery-bay release latch

Place the battery above a small plastic cover hiding the PokePlate release. With the battery removed, pull the release switch so the cover pops loose, rotate the small hook, then slide the PokePlate carrier out. Wire one presence-switch leg to GND (ground) and the other to GPIO18 (fully-seated signal).

  • Set the hook so it presses the presence switch only when the PokePlate is fully seated.
  • Test the latch several times with power disconnected before closing the case.
  • Do not force the carrier past the hook — bent connector contacts can cause unreliable collection reads.

5. Wire the controls and class detector

For each button, connect one leg to GND and the other leg to its signal: Up to GPIO1 (signal), Down to GPIO2 (signal), R to GPIO3 (signal), T to GPIO4 (signal), A to GPIO5 (signal), and B to GPIO6 (signal). Connect one class-open switch leg to GND (ground) and the other to GPIO7 (signal).

  • The board enables internal pull-up resistors, so each button only needs to connect its input to ground when pressed.
  • Test every button before the final enclosure assembly.
  • Do not connect any button signal to 5 V — ESP32 inputs use 3.3 V logic and can be damaged by 5 V.

6. Add the battery-backed clock

Install the CR1220 cell in the clock board. Connect RTC VIN to 3V3 (power), GND to GND (ground), SDA to GPIO8 (time data), and SCL to GPIO9 (time clock). This preserves the two-hour class lock during a normal battery change.

  • Keep the backup cell installed so time does not reset when the main battery is removed.
  • Install the coin cell with its positive side aligned to the holder's positive marking — a reversed cell can leak or fail.

7. Close and test the handheld

Route wires so the battery can be removed without pulling on the PokePlate connector. Insert a provisioned PokePlate, reconnect the battery, and close the case. The screen should report that the PokePlate is verified. Nearby trading uses the ESP32-S3's built-in Wi-Fi/Bluetooth radio; no separate wireless module is needed.

  • Keep the ESP32-S3 antenna area clear of the battery, metal screws, and large ground wires so nearby trading range is not reduced.
  • Label the PokePlate carrier as removable collection storage.
  • Stop if a module gets hot, smells unusual, or the battery swells — disconnect power before checking the wiring.

Review all connections

1. Connections between "pokeplate_sd" and "ESP32"

Functionpokeplate_sdESP32
powerVCC3V3
groundGNDGND
spiSCKGPIO 12
spiMOSIGPIO 11
spiMISOGPIO 13
spiCSGPIO 17

2. Connections between "pokeplate_secure" and "ESP32"

Functionpokeplate_secureESP32
powerVCC3V3
groundGNDGND
i2cSDAGPIO 8
i2cSCLGPIO 9

3. Connections between "btn_up" and "ESP32"

Functionbtn_upESP32
groundGNDGND
digitalSIGNALGPIO 1

4. Connections between "btn_down" and "ESP32"

Functionbtn_downESP32
groundGNDGND
digitalSIGNALGPIO 2

5. Connections between "btn_r" and "ESP32"

Functionbtn_rESP32
groundGNDGND
digitalSIGNALGPIO 3

6. Connections between "btn_t" and "ESP32"

Functionbtn_tESP32
groundGNDGND
digitalSIGNALGPIO 4

7. Connections between "btn_a" and "ESP32"

Functionbtn_aESP32
groundGNDGND
digitalSIGNALGPIO 5

8. Connections between "btn_b" and "ESP32"

Functionbtn_bESP32
groundGNDGND
digitalSIGNALGPIO 6

9. Connections between "class_open_switch" and "ESP32"

Functionclass_open_switchESP32
groundGNDGND
digitalSIGNALGPIO 7

10. Connections between "pokeplate_present" and "ESP32"

Functionpokeplate_presentESP32
groundGNDGND
digitalSIGNALGPIO 18

11. Connections between "battery_1" and "ESP32"

Functionbattery_1ESP32
power+VTP4056 Li-Ion/LiPo charger module with protection B+EXT
groundGNDTP4056 Li-Ion/LiPo charger module with protection B-EXT

12. Connections between "charger_1" and "ESP32"

Functioncharger_1ESP32
powerIN+USB-C 5 V charging socket positiveEXT
groundIN-USB-C charging socket groundEXT
powerOUT+Boost Converter VIN+EXT
groundOUT-Boost Converter VIN-EXT

13. Connections between "boost_1" and "ESP32"

Functionboost_1ESP32
powerVOUT+VIN
groundVOUT-GND

14. Connections between "rtc_1" and "ESP32"

Functionrtc_1ESP32
powerVIN3V3
groundGNDGND
i2cSDAGPIO 8
i2cSCLGPIO 9

15. Connections between "display_1" and "ESP32"

Functiondisplay_1ESP32
powerVCC3V3
groundGNDGND
spiSCKGPIO 12
spiMOSIGPIO 11
spiCSGPIO 10
digitalDCGPIO 14
digitalRESETGPIO 15
pwmLEDGPIO 16

Deploy the firmware

#include <Arduino.h>
#include <Wire.h>
#include <SPI.h>
#include <SD.h>
#include <WiFi.h>
#include <esp_now.h>
#include <Preferences.h>
#include <RTClib.h>
#include <Arduino_GFX_Library.h>

#define PIN_DOWN 2
#define PIN_R 3
#define PIN_T 4
#define PIN_A 5
#define PIN_B 6
#define PIN_CLASS_OPEN 7
#define I2C_SCL 9
#define TFT_SCK 12
#define SD_CS 17
#define PIN_PLATE_PRESENT 18
#define TFT_DC 14
#define TFT_RST 15
#define TFT_BL 16

// Generic prototype UI only. Do not ship third-party character names/art without permission.
enum class UiState : uint8_t { HOME, TRADE_SELECT, TRADE_WAIT, TRADE_ANIMATION, CLASS_WARNING, CLASS_FINAL, CLASS_LOCKED, PLATE_ERROR, TAMPER_LOCKED, TERMINAL };

// Forward declarations
uint32_t nowEpoch();
bool isPressed(int pin);
void center(const String &s, int y, uint16_t color, uint8_t size);
void drawScreen();
bool authenticatePokePlate();
bool forbiddenFastkey(const String &macro);
bool mapFastkey(char key,const String &macro);
void appendInput(char key);
void handleClassOpenAttempt();
void onPeerMessage(const esp_now_recv_info_t*, const uint8_t *data, int len);
void startWirelessTrading();
void sendTradeOffer();
void pollButtons();

constexpr int TFT_CS=10, TFT_DC=14, TFT_RST=15, TFT_BL=16;
constexpr int TFT_MOSI=11, TFT_SCK=12, TFT_MISO=13, SD_CS=17;
constexpr int PIN_UP=1, PIN_DOWN=2, PIN_R=3, PIN_T=4, PIN_A=5, PIN_B=6, PIN_CLASS_OPEN=7, PIN_PLATE_PRESENT=18;
constexpr int I2C_SDA=8, I2C_SCL=9;
constexpr uint32_t CLASS_BAN_SECONDS=7200UL, TAMPER_LOCK_SECONDS=360000UL;
constexpr uint8_t CLASS_ATTEMPT_LIMIT=25;
constexpr char DEV_SEQUENCE[]="RTUUDDTRDRRUPTTRUDTTTTRRUDR";
constexpr size_t DEV_SEQUENCE_LEN=sizeof(DEV_SEQUENCE)-1;
constexpr uint8_t ATECC608A_ADDRESS=0x60;

Arduino_DataBus *displayBus = new Arduino_ESP32SPI(TFT_DC, TFT_CS, TFT_SCK, TFT_MOSI, GFX_NOT_DEFINED, VSPI);
Arduino_GFX *gfx = new Arduino_ILI9488(displayBus, TFT_RST, 1, false);
RTC_DS3231 rtc;
Preferences prefs;
UiState uiState=UiState::HOME;
uint8_t classOpenAttempts=0;
uint32_t classBanUntil=0, invalidPlateSeconds=0, lastSecond=0;
String inputHistory, fastkeyA, fastkeyB;
bool plateAuthenticated=false, previousClassSwitch=false, previousPlatePresent=false;
volatile bool peerOfferReceived=false;

uint32_t nowEpoch(){ return rtc.now().unixtime(); }
bool isPressed(int pin){ return digitalRead(pin)==LOW; }
void center(const String &s, int y, uint16_t color, uint8_t size=2){
  gfx->setTextSize(size); gfx->setTextColor(color, BLACK); int16_t x1,y1; uint16_t w,h;
  gfx->getTextBounds(s,0,y,&x1,&y1,&w,&h); gfx->setCursor((gfx->width()-w)/2,y); gfx->print(s);
}
void drawScreen(){
  gfx->fillScreen(BLACK);
  switch(uiState){
    case UiState::HOME: center("E-TRADER V1",30,YELLOW,3); center(plateAuthenticated?"PokePlate verified":"Insert verified PokePlate",95,plateAuthenticated?GREEN:RED,2); center("Wi-Fi / Bluetooth trading ready",150,WHITE,1); center("T: trade    A/B: fastkey",205,WHITE,1); break;
    case UiState::TRADE_SELECT: center("Choose a card",35,CYAN,3); center("Press UP to offer",115,WHITE,2); center("R cancels",195,WHITE,1); break;
    case UiState::TRADE_WAIT: center("Offer sent",45,YELLOW,3); center("Looking for nearby peer",120,WHITE,2); break;
    case UiState::TRADE_ANIMATION: center("Trade complete",30,GREEN,3); gfx->fillRoundRect(180,90,120,150,10,RED); center("Your card moves up",270,WHITE,2); break;
    case UiState::CLASS_WARNING: center("Complete lesson first.",140,WHITE,2); break;
    case UiState::CLASS_FINAL: center("I SAID COMPLETE",75,MAGENTA,3); center("LESSON",120,MAGENTA,3); center("Class usage 2 hour ban",210,YELLOW,2); break;
    case UiState::CLASS_LOCKED: center("Good night",115,CYAN,4); center("Class lock active",185,WHITE,2); break;
    case UiState::PLATE_ERROR: center("Unverified PokePlate",100,RED,3); center("Remove it and use a provisioned plate.",165,RED,1); break;
    case UiState::TAMPER_LOCKED: center("Profile protected",100,RED,3); center("Service recovery required",165,YELLOW,2); break;
    case UiState::TERMINAL: center("Developer terminal",55,GREEN,3); center("Restricted service mode",115,WHITE,2); break;
  }
}

// Fails closed. Replace this presence/manifest probe with ATECC608A nonce-signature
// verification and a manufacturer public key before a production release.
bool authenticatePokePlate(){
  if(!isPressed(PIN_PLATE_PRESENT)||!SD.begin(SD_CS)) return false;
  Wire.beginTransmission(ATECC608A_ADDRESS); if(Wire.endTransmission()!=0) return false;
  File manifest=SD.open("/collection.manifest",FILE_READ); if(!manifest) return false;
  String header=manifest.readStringUntil('\n'); manifest.close(); return header=="ETRADER-PLATE-V1";
}
bool forbiddenFastkey(const String &macro){ return macro.indexOf(DEV_SEQUENCE)>=0; }
bool mapFastkey(char key,const String &macro){
  if(!macro.length()||macro.length()>64||forbiddenFastkey(macro)) return false;
  if(key=='A'){fastkeyA=macro; prefs.putString("fastA",macro); return true;}
  if(key=='B'){fastkeyB=macro; prefs.putString("fastB",macro); return true;} return false;
}
void appendInput(char key){
  inputHistory+=key; if(inputHistory.length()>DEV_SEQUENCE_LEN) inputHistory.remove(0,inputHistory.length()-DEV_SEQUENCE_LEN);
  if(inputHistory==DEV_SEQUENCE){uiState=UiState::TERMINAL; inputHistory=""; drawScreen();}
}
void handleClassOpenAttempt(){
  if(nowEpoch()<classBanUntil){uiState=UiState::CLASS_LOCKED; drawScreen(); return;}
  prefs.putUChar("classN",++classOpenAttempts);
  if(classOpenAttempts>=CLASS_ATTEMPT_LIMIT){classBanUntil=nowEpoch()+CLASS_BAN_SECONDS; prefs.putULong("banUntil",classBanUntil); classOpenAttempts=0; prefs.putUChar("classN",0); uiState=UiState::CLASS_FINAL; drawScreen(); delay(1800); uiState=UiState::CLASS_LOCKED;}
  else uiState=UiState::CLASS_WARNING;
  drawScreen();
}

// ESP-NOW uses the ESP32-S3's onboard 2.4 GHz Wi-Fi radio for nearby offers.
// Production trading must add mutual plate attestation and signed atomic updates.
void onPeerMessage(const esp_now_recv_info_t*, const uint8_t *data, int len){
  if(len==5 && memcmp(data,"OFFER",5)==0) peerOfferReceived=true;
}
void startWirelessTrading(){
  WiFi.mode(WIFI_STA);
  if(esp_now_init()==ESP_OK) esp_now_register_recv_cb(onPeerMessage);
}
void sendTradeOffer(){
  const uint8_t offer[]={'O','F','F','E','R'};
  const uint8_t broadcast[]={0xFF,0xFF,0xFF,0xFF,0xFF,0xFF};
  esp_now_peer_info_t peer={}; memcpy(peer.peer_addr,broadcast,6); peer.channel=0; peer.encrypt=false;
  if(!esp_now_is_peer_exist(broadcast)) esp_now_add_peer(&peer);
  esp_now_send(broadcast,offer,sizeof(offer));
}
void pollButtons(){
  static uint32_t lastButton=0; if(millis()-lastButton<160) return; char key=0;
  if(isPressed(PIN_R))key='R'; else if(isPressed(PIN_T))key='T'; else if(isPressed(PIN_UP))key='U'; else if(isPressed(PIN_DOWN))key='D'; else if(isPressed(PIN_A))key='A'; else if(isPressed(PIN_B))key='B';
  if(!key)return; lastButton=millis(); if(key=='R'||key=='T'||key=='U'||key=='D')appendInput(key);
  if(uiState==UiState::TERMINAL||uiState==UiState::CLASS_LOCKED||uiState==UiState::TAMPER_LOCKED)return;
  if(key=='T'&&plateAuthenticated){uiState=UiState::TRADE_SELECT;drawScreen();}
  else if(key=='U'&&uiState==UiState::TRADE_SELECT){sendTradeOffer();uiState=UiState::TRADE_WAIT;drawScreen();}
  else if(key=='R'){uiState=UiState::HOME;drawScreen();}
}
void setup(){
  pinMode(TFT_BL,OUTPUT); digitalWrite(TFT_BL,HIGH);
  for(int pin:{PIN_UP,PIN_DOWN,PIN_R,PIN_T,PIN_A,PIN_B,PIN_CLASS_OPEN,PIN_PLATE_PRESENT})pinMode(pin,INPUT_PULLUP);
  SPI.begin(TFT_SCK,TFT_MISO,TFT_MOSI,SD_CS); Wire.begin(I2C_SDA,I2C_SCL); rtc.begin();
  gfx->begin(); gfx->setRotation(1); prefs.begin("etrader",false); startWirelessTrading();
  classOpenAttempts=prefs.getUChar("classN",0); classBanUntil=prefs.getULong("banUntil",0); invalidPlateSeconds=prefs.getULong("badSecs",0);
  fastkeyA=prefs.getString("fastA",""); fastkeyB=prefs.getString("fastB",""); plateAuthenticated=authenticatePokePlate();
  uiState=invalidPlateSeconds>=TAMPER_LOCK_SECONDS?UiState::TAMPER_LOCKED:(nowEpoch()<classBanUntil?UiState::CLASS_LOCKED:(plateAuthenticated?UiState::HOME:UiState::PLATE_ERROR));
  previousClassSwitch=isPressed(PIN_CLASS_OPEN); previousPlatePresent=isPressed(PIN_PLATE_PRESENT); lastSecond=millis(); drawScreen();
}
void loop(){
  bool classSwitch=isPressed(PIN_CLASS_OPEN); if(classSwitch&&!previousClassSwitch)handleClassOpenAttempt(); previousClassSwitch=classSwitch;
  bool platePresent=isPressed(PIN_PLATE_PRESENT); if(platePresent!=previousPlatePresent){plateAuthenticated=authenticatePokePlate(); if(uiState!=UiState::TAMPER_LOCKED&&nowEpoch()>=classBanUntil){uiState=plateAuthenticated?UiState::HOME:UiState::PLATE_ERROR;drawScreen();}} previousPlatePresent=platePresent;
  if(millis()-lastSecond>=1000){lastSecond+=1000; if(!plateAuthenticated&&platePresent&&uiState!=UiState::TAMPER_LOCKED){invalidPlateSeconds++;if(invalidPlateSeconds%60==0)prefs.putULong("badSecs",invalidPlateSeconds);if(invalidPlateSeconds>=TAMPER_LOCK_SECONDS){prefs.putULong("badSecs",invalidPlateSeconds);uiState=UiState::TAMPER_LOCKED;drawScreen();}} if(uiState==UiState::CLASS_LOCKED&&nowEpoch()>=classBanUntil){uiState=plateAuthenticated?UiState::HOME:UiState::PLATE_ERROR;drawScreen();}}
  if(peerOfferReceived&&uiState==UiState::TRADE_WAIT){peerOfferReceived=false;uiState=UiState::TRADE_ANIMATION;drawScreen();}
  pollButtons();
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik