Community project
Secure Pokémon Card Trading Device
Generated with AIThis 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
Interactive · read-only
Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.
Parts list
Bill of materials| Component | Qty | Notes |
|---|---|---|
| MicroSD Card ModulePokePlate removable storage | 1 | 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. |
| ATECC608A secure-element breakoutPokePlate identity chip | 1 | Cryptographic identity chip in the removable PokePlate that proves the collection was issued by this project. |
| LiPo 3.7V 1000mAh Battery3.7 V 1000 mAh | 1 | 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. |
| TP4056 Li-Ion/LiPo charger module with protectionprotected 6-pad module | 1 | 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. |
| Boost Converterset to 5.0 V | 1 | 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. |
| Push ButtonUp | 1 | Momentary push button switch |
| Push ButtonDown | 1 | Momentary push button switch |
| Push ButtonR Reset | 1 | Momentary push button switch |
| Push ButtonT Trade | 1 | Momentary push button switch |
| Push ButtonA Fastkey | 1 | Momentary push button switch |
| Push ButtonB Fastkey | 1 | Momentary push button switch |
| Class-open attempt switchnormally-open case switch | 1 | A case-interlock switch that records each attempt to open the handheld during a class session. |
| PokePlate presence switchnormally-open latch detector | 1 | A normally-open microswitch that confirms the PokePlate cover and hook are fully latched. |
| Adafruit DS3231 Precision RTC Breakoutwith CR1220 backup cell | 1 | DS3231 precision real-time clock breakout with integrated TCXO and crystal for accurate timekeeping over I2C. |
| ILI9488 3.5 inch SPI TFT Display3.5 in 480×320 full-color RGB TFT | 1 | 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. |
Assembly
7 stepsPrepare 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.
- Tip: Keep the battery in a holder or pocket so it cannot be punctured.
- Tip: 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.
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).
- Tip: This 480×320 RGB TFT is much larger than the former display, so make the front opening about 85 mm by 56 mm.
- Tip: 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.
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).
- Tip: Use a keyed connector so the carrier cannot be inserted backward.
- Tip: 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.
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).
- Tip: Set the hook so it presses the presence switch only when the PokePlate is fully seated.
- Tip: 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.
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).
- Tip: The board enables internal pull-up resistors, so each button only needs to connect its input to ground when pressed.
- Tip: 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.
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.
- Tip: 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.
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.
- Tip: Keep the ESP32-S3 antenna area clear of the battery, metal screws, and large ground wires so nearby trading range is not reduced.
- Tip: 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.
Pin assignments
Board wiring reference| Pin | Connection | Type |
|---|---|---|
| 3V3 | pokeplate_sd VCC | power |
| GND | pokeplate_sd GND | ground |
| GPIO 12 | pokeplate_sd SCK | spi |
| GPIO 11 | pokeplate_sd MOSI | spi |
| GPIO 13 | pokeplate_sd MISO | spi |
| GPIO 17 | pokeplate_sd CS | spi |
| 3V3 | pokeplate_secure VCC | power |
| GND | pokeplate_secure GND | ground |
| GPIO 8 | pokeplate_secure SDA | i2c |
| GPIO 9 | pokeplate_secure SCL | i2c |
| GND | btn_up GND | ground |
| GPIO 1 | btn_up SIGNAL | digital |
| GND | btn_down GND | ground |
| GPIO 2 | btn_down SIGNAL | digital |
| GND | btn_r GND | ground |
| GPIO 3 | btn_r SIGNAL | digital |
| GND | btn_t GND | ground |
| GPIO 4 | btn_t SIGNAL | digital |
| GND | btn_a GND | ground |
| GPIO 5 | btn_a SIGNAL | digital |
| GND | btn_b GND | ground |
| GPIO 6 | btn_b SIGNAL | digital |
| GND | class_open_switch GND | ground |
| GPIO 7 | class_open_switch SIGNAL | digital |
| GND | pokeplate_present GND | ground |
| GPIO 18 | pokeplate_present SIGNAL | digital |
| EXT | battery_1 +V → TP4056 Li-Ion/LiPo charger module with protection B+ | power |
| EXT | battery_1 GND → TP4056 Li-Ion/LiPo charger module with protection B- | ground |
| EXT | charger_1 IN+ → USB-C 5 V charging socket positive | power |
| EXT | charger_1 IN- → USB-C charging socket ground | ground |
| EXT | charger_1 OUT+ → Boost Converter VIN+ | power |
| EXT | charger_1 OUT- → Boost Converter VIN- | ground |
| VIN | boost_1 VOUT+ | power |
| GND | boost_1 VOUT- | ground |
| 3V3 | rtc_1 VIN | power |
| GND | rtc_1 GND | ground |
| GPIO 8 | rtc_1 SDA | i2c |
| GPIO 9 | rtc_1 SCL | i2c |
| 3V3 | display_1 VCC | power |
| GND | display_1 GND | ground |
| GPIO 12 | display_1 SCK | spi |
| GPIO 11 | display_1 MOSI | spi |
| GPIO 10 | display_1 CS | spi |
| GPIO 14 | display_1 DC | digital |
| GPIO 15 | display_1 RESET | digital |
| GPIO 16 | display_1 LED | pwm |
Firmware
ESP32#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 ¯o);
bool mapFastkey(char key,const String ¯o);
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 ¯o){ return macro.indexOf(DEV_SEQUENCE)>=0; }
bool mapFastkey(char key,const String ¯o){
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();
}“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.
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.