MTG RFID Companion
A hand-held Magic: The Gathering companion built on an ESP32-WROOM-32. Tap an RFID/NFC-tagged card to a built-in reader and the device:
- renders the card's cover art and metadata on a 2.0" ST7789 TFT (240x320),
- flashes a WS2812B Neopixel in the card's mana color and plays a short buzzer chirp,
- keeps a life counter (20/40) you adjust with a rotary encoder,
- caches cards locally on LittleFS and refetches from the Scryfall API over Wi-Fi,
- runs a SoftAP registration server (
MTG-Companion) so you can pair an unknown tag to a card name from your phone.
All code is pure ESP-IDF v5.x (C) / FreeRTOS — no Arduino.
Features
| 🃏 RFID scanning | MFRC522, NTAG21x / MIFARE tags, 4- and 7-byte UIDs |
| 🖥️ Display | ST7789 240x320 RGB565, 5x7 text, scaled digits, streaming JPEG decode |
| 🌐 Network | Wi-Fi STA auto-connect, Scryfall HTTPS with local cache |
| 📡 Pairing | SoftAP MTG-Companion (192.168.4.1) web SPA for UID ↔ card name |
| 🔄 UX | EC11 rotary encoder, backlit life counter, mana-colored LED, audio cues |
| 💾 Storage | LittleFS /spiffs (~1.9MB) seeded with card mapping + example art |
Hardware Requirements
- ESP32-WROOM-32 dev board (4MB flash, no PSRAM required)
- MFRC522 RFID reader (13.56 MHz, SPI)
- ST7789 SPI TFT, 240x320 (e.g. common 2.0-2.4" breakout)
- EC11 rotary encoder with push switch
- WS2812B / Neopixel (single LED)
- Passive piezo buzzer
- Antenna-friendly NTAG213/NTAG215 stickers or MIFARE cards, ~3.3V–5V supply
Wiring Guide
Do not change these GPIO assignments in code unless you update both
main/hw_pins.hand this table. The ST7789 and MFRC522 share one SPI bus (SPI2_HOST).
Pin map
| Peripheral | Function | ESP32 GPIO | Notes |
|---|
Display and reader are on two separate SPI controllers: the ST7789 on SPI2_HOST (VSPI), the MFRC522 on SPI3_HOST (HSPI) — so the panel and the RFID reader never share bus timing.
Pin map
| Peripheral | Function | ESP32 GPIO | Notes |
|---|---|---|---|
| ST7789 (7-pin) — SPI2_HOST | SCL | 18 | SPI2 (VSPI) clock |
| SDA | 23 | SPI2 MOSI (data) | |
| CS | 15 | dedicated chip select | |
| DC | 2 | data/command | |
| RST | 4 | panel reset | |
| VCC / GND | 3.3V / GND | panel power (no separate LED pin) | |
| MFRC522 — SPI3_HOST | SCK | 16 | SPI3 (HSPI) clock (own bus) |
| MOSI | 17 | SPI3 data out | |
| MISO | 13 | SPI3 data in | |
| SDA (CS) | 26 | dedicated chip select | |
| RST | 22 | hard reset | |
| 3.3V / GND | — | feed from board 3.3V rail | |
| EC11 | CLK | 32 | interrupt input, pull-up |
| DT | 33 | quadrature B | |
| SW | 25 | button, active low, pull-up | |
| WS2812B | DIN | 27 | RMT Tx; supply 5V/VDD, GND common |
| Buzzer | SIG | 14 | LEDC PWM |
Suggested breadboard layout
ESP32-WROOM-32
┌────────────────────────────┐
│ 3V3 16 ── SCK ────────┐ │
│ GND 17 ── MOSI ────┐ │ │
│ 13 ── MISO ────┼──┘ │
│ 26 ── SDA(CS) ─┘ │
│ 22 ── RST ─┐ │
│ 15 ── CS ──┼───────┤ ST7789 (7-pin)
│ 2 ── DC ──┤ │ SCL = 18, SDA = 23
│ 4 ── RST ─┘ │ VCC = 3V3, GND = GND
│ 18 ── SCL ─────────┘
│ 23 ── SDA ───────── (display = SPI2)
│ 32 ── CLK ────────┤ EC11 33 ── DT 25 ── SW/GND
│ 27 ── Din ────────┤ WS2812B (5V + GND)
│ 14 ── SIG ────────┤ Buzzer (GND)
└────────────────────────────┘
MFRC522 (own SPI3/HSPI bus): SCK→16, MOSI→17, MISO→13, SDA(CS)→26, RST→22, VCC→3.3V, GND→GND. It shares no lines with the display.
ST7789 (SPI2/VSPI): SCL→18, SDA→23, CS→15, DC→2, RST→4, VCC→3.3V, GND→GND.
- Keep SPI signal runs short (< 10 cm) for reliable 20 MHz display transfers.
- NTAG213/215 FPC stickers need to sit flat and close to the MFRC522 antenna.
- The WS2812B draws up to ~60 mA; power it from the 5V rail, never from a GPIO.
Getting Started
1. Toolchain
Install ESP-IDF v5.5 (any 5.x works): https://docs.espressif.com/projects/esp-idf/en/latest/esp32/get-started/
Verify:
# Windows (Espressif "ESP-IDF 5.5 PowerShell" terminal)
. C:\Espressif\Initialize-Idf.ps1 # or: . $IDF_PATH\export.ps1
idf.py --version
Never used Espressif tools before? Skip ahead to "Changing the Wi-Fi without the toolchain"; the section below is optional.
2. Build
idf.py set-target esp32
idf.py build
The custom partition table (partitions.csv) reserves:
| Offset | Size | Subtype | Purpose |
|---|---|---|---|
| 0x10000 | 2 MB | app/factory | firmware |
| 0x210000 | ~1.9 MB | data/littlefs | /spiffs cache (seeded from storage/) |
3. Configure Wi-Fi
Two equivalent ways:
# a) interactive menu (recommended)
idf.py menuconfig # -> "MTG RFID Companion" -> SSID + password
# b) edit the preset file directly
# sdkconfig.defaults.esp32
Leaving the SSID empty (or keeping the YOUR_... placeholders) disables
station mode. The SoftAP pairing server runs regardless, so the device stays
usable as a fully standalone scanner — no home network required.
What "station mode disabled" actually means
The Wi-Fi radio still turns on, but only as an access point:
wifi_manager_start()sees the blank/placeholder SSID and returns early — it never callsesp_wifi_connect(), so the device won't try to join any router (and won't pretend to be online).web_server_start()starts unconditionally: SoftAPMTG-Companionat192.168.4.1(own DHCP) plus the/api/last_uidand/api/bindendpoints.net_taskskips the 60-second connect wait and the boot-time prefetch. Card lookups only run when you explicitly trigger one, and those fail fast (no internet route) with ascryfalllog line — they never block the UI.
What works and what doesn't
| Feature | In AP-only mode | Notes |
|---|---|---|
| RFID scanning, life counter, LED/buzzer | ✔ | fully local |
| Showing already-cached cards (art + stats) | ✔ | served from /spiffs |
| Binding a UID to a card name | ✔ | written to /spiffs/mappings.json immediately |
| Scryfall lookup + art download | ✘ | the device has no internet here; the fetch fails and is logged. It completes automatically on a later boot with Wi-Fi configured — the boot-time prefetch walks mappings.json and downloads anything missing |
| Scryfall autocomplete in the pairing page | ⚠️ | the AP doesn't pass internet through (no NAT). A phone with cellular data keeps its mobile connection, so autocomplete works there; on a device with no other internet path, type the exact card name manually |
Use case: AP-only mode is the normal "deck on the table" flow — mid-game you almost never need live Scryfall lookups. When you do (new card, missing art), configure Wi-Fi once, boot briefly, and the binding + art cache update themselves via prefetch.
Changing the Wi-Fi without the toolchain
If you're new to this and haven't installed any Espressif software yet, start here.
The Wi-Fi network the device connects to is baked into the firmware at compile time — there is no on-device settings menu yet. To change it you must edit one text file and produce a new firmware image. Here is the shortest ESPRESSIF-FREE path to do that, with zero drivers or toolchains.
The one file you edit
Open sdkconfig.defaults.esp32 in any text editor (Notepad is fine) and
find these two lines:
CONFIG_MTG_WIFI_SSID="YOUR_SSID_HERE"
CONFIG_MTG_WIFI_PASS="YOUR_PASSWORD_HERE"
Replace the placeholders with your network name and password, keeping the quotes:
CONFIG_MTG_WIFI_SSID="MyHomeWiFi"
CONFIG_MTG_WIFI_PASS="correct horse battery staple"
- Keep the file UTF-8 without BOM, and don't add spaces inside the quotes.
- If your password has a
"or\in it, this file format can't represent it (rare) — pick that one character out of the password or use the toolchain menu instead. - Want to go back to offline-only later? Restore the
YOUR_...placeholders and rebuild.
Getting a new firmware image from this file
The repo cannot build firmware without the toolchain. Your options, easiest first:
| Path | What's needed | How |
|---|---|---|
| Ask whoever gave you the device | a minute of their time | Send them the edited file; they run idf.py build and hand you a new .bin |
| One-time toolchain install (~45 min, guided) | the Espressif installer (free, Windows/macOS/Linux) | Follow the official guide: https://docs.espressif.com/projects/esp-idf/en/latest/esp32/get-started/ then run the 5 commands in Build |
| CI build (GitHub Actions etc.) | a GitHub account | The repo can build on ubuntu in CI; push the edited file and download the built mtg-rfid-companion.bin artifact |
Flashing the new firmware (no toolchain needed)
Flash using Espressif Flash Download Tool (graphical, no toolchain):
https://docs.espressif.com/projects/esp-idf/en/latest/esp32/get-started/linux-setup-scratch.html#esp32-flash-download-tool
or esptool.py if you already have Python:
pip install esptool
esptool.py -p COM3 --chip esp32 --before default_reset write_flash --flash_mode dio --flash_size 4MB 0x1000 bootloader.bin 0x8000 partition-table.bin 0x10000 mtg-rfid-companion.bin
TL;DR
- Edit
sdkconfig.defaults.esp32→ 2. rebuild (yours or someone's toolchain) → - flash the
.bin→ 4. done. If you don't need your home Wi-Fi, skip the whole thing: the device works standalone and has its own AP.
4. Flash & watch
idf.py -p COMx flash monitor # COMx = your serial port
Expected boot log if everything is wired right:
I storage: LittleFS mounted at /spiffs: <size> bytes total ...
I storage: boot_count read back = 1 (persistence across reboots OK)
I rfid: RC522 polling task pinned to Core 0
I display: ST7789 240x320 ready ...
I web: SoftAP 'MTG-Companion' at 192.168.4.1, web server ready
I ui: UI task on core 1
RFID reader missing / not wired? That's OK. A reader self-test failure no longer aborts boot — the device logs a warning and continues into the display, SoftAP and web-pairing screens without card scanning:
Usage
Scan a card
Place a tagged card on the reader:
- Mapped UID → chirp, mana-colored LED flash, then the card view (cover art + name/type/P·T/price/mana).
- Unknown UID → registration screen with the pairing address.
Life counter
Press the encoder button to toggle SCANNER ↔ LIFE_COUNTER. Rotate to adjust (20/40 preset or whatever you last set; press again to reset to the preset).
Web pairing (registration mode)
- Connect your phone to the
MTG-Companionaccess point. A phone with cellular data keeps its mobile internet, so Scryfall autocomplete works; otherwise type the name manually. - Open
http://192.168.4.1. - Scan an unmapped card — the UID appears in the page.
- Type / pick a Scryfall-autocomplete name → Pair UID with card.
- The mapping persists to
/spiffs/mappings.json. The card data/art fetch runs immediately if the device has Wi-Fi, or is picked up automatically by the boot-time prefetch the next time you boot with Wi-Fi configured.
Storage Layout (/spiffs)
/spiffs/
├── mappings.json UID -> Card Name map
├── cards/
│ ├── sample.json example metadata
│ ├── sample.jpg example cover art (baked into flash image)
│ ├── <UID>.json Scryfall metadata cache
│ └── <UID>.jpg cached cover art
The storage/ folder is compiled into the LittleFS partition image at build
time (littlefs_create_partition_image), so a fresh flash already contains
mappings.json and the example art.
Troubleshooting
| Symptom | Fix |
|---|---|
| Display = black / garbage | Check CS/DC/RST wiring; try toggling esp_lcd_panel_invert_color and LCD_RGB_ELEMENT_ORDER_* in display_manager.c |
| Colors inverted | Same toggle as above — panel clones differ |
| No UID printed on scan | Verify MFRC522 wiring, tag type (ISO14443A), antenna positioning; confirm rfid: RC522 polling task pinned to Core 0 in the log |
Boot log: rc522: FIFO length missmatch / RTOS: RFID unavailable |
Reader self-test failed. Check the MFRC522 has 3.3V power (many clones have a separate power pin), CS=GPIO5 / RST=GPIO22, and SCK/MOSI/MISO=18/23/19. Try lowering RC522_SPI_CLK_HZ in rfid_manager.c (e.g. 2000000); some clones need a slower clock on a shared bus |
| Wi-Fi never connects | Re-run idf.py menuconfig, confirm you replaced the YOUR_... placeholders |
| Scryfall autocomplete shows nothing | The AP has no internet passthrough — the phone needs its own mobile data connection, or type the card name manually |
| Can't reach 192.168.4.1 | Ensure your phone joined MTG-Companion, not a cached network |
Flash fails: Serial data stream stopped / must unplug the display to flash |
Disconnect the ST7789 (or power it down) during idf.py flash: this board's display shares strapping pins GPIO2/GPIO15 and the panel can pull the 3.3V rail / strap pins at reset, corrupting the serial link mid-write. Flash, then reconnect the display. |
RFID step of boot log: Buffers content missmatch, buffer2 = 13 33 37 |
The reader now answers (SPI OK) but glitches on the write/read race. RC522_SPI_CLK_HZ is already 2 MHz; lower further to 1000000 in rfid_manager.c, or check the MFRC522's separate power header gets a clean 3.3V and confirm MISO=GPIO19 is actually connected |
| Card renders but no art | Card not cached yet — check scryfall log lines; art downloads are best-effort |
| App grows near 2MB | mbedTLS + HTTP pulls are large; if needed, enable CONFIG_MBEDTLS_CERTIFICATE_BUNDLE (smaller) |
Project Layout
├── CMakeLists.txt top-level; bakes LittleFS storage image
├── partitions.csv custom partition table
├── sdkconfig.defaults shared ESP32 defaults
├── sdkconfig.defaults.esp32 Wi-Fi credential preset (edit me)
├── storage/ files pre-seeded into /spiffs
├── tools/ host-side helpers (sample JPEG generator)
└── main/
├── hw_pins.h GPIO assignments (authoritative)
├── spi_bus_manager.c shared SPI2_HOST bus
├── storage_manager.c LittleFS mount + JSON helpers
├── rfid_manager.c MFRC522 / Core 0 polling
├── display_manager.c ST7789 + fonts + streaming JPEG
├── ui_task.c state machine + screens (Core 1)
├── wifi_manager.c STA connect/reconnect
├── scryfall_client.c Scryfall HTTPS + cache (net_task)
├── web_server.c SoftAP pairing server
├── encoder.c EC11 ISR decode
├── led_manager.c WS2812B RMT
├── buzzer.c LEDC PWM tones
└── app_state.c shared app/life-counter state
See AGENTS.md for the detailed architecture, core split, and coding
conventions used across this repository.