See it running
The short version
- Godot 4.6 GDExtension for a client: save a loadout and keybinds on an NTAG215, load that card on another Windows PC.
- The addon moves raw bytes. The game packs and unpacks its own struct.
- Writes start at user page 4, cap at 100 bytes, and verify each page before success.
- Failures are numeric codes (card removed, wrong tag, verify failed), plus one card_error signal.
- 15 native tests on a fake PC/SC transport. The client confirmed the same API on a real reader.
- MIT licensed. Delivered as source, DLL, demo, and a short install doc.
What it does
- Windows x86-64 GDExtension (godot-cpp, WinSCard). MIT licensed, with source and a compiled DLL
- Reads and writes 1–100 raw bytes on an NTAG215, starting at page 4. UID, lock bytes, password, and config pages stay untouched
- open() checks the capability container (E1 10 3E 00) and refuses a different tag before any write
- write_bytes reads every page back and fails with VERIFY_FAILED if the card does not match
- A short write keeps the unused bytes on the last partial page. No header, checksum, or schema is added
- card_error fires once per failed call. Game logic uses numeric codes: no card, removed mid-op, wrong tag, protected, verify failed
- Demo scene lists readers, shows the 7-byte UID, and prints hex plus UTF-8
- 15 native tests drive a fake PC/SC transport. The client’s reader was the hardware pass
Built for a client
Commissioned for a Windows desktop twin-stick shooter. I owned the GDExtension, the demo, the docs, and the native tests. The client packs their own loadout in GDScript. They verified it on their NTAG215 cards and a PC/SC reader.
The thought
The client is making a Windows twin-stick shooter and wanted a player to carry their loadout and keybinds on an NFC card, then drop that card on a different PC and keep playing. They already had NTAG215 cards. They wanted a GDScript tool they could call themselves, with the game in charge of the byte layout.
Mechanics
Plug in a PC/SC reader, hit Refresh Readers, set an NTAG215 on it, and hit Open Card. A 7-byte UID shows up. Type 1–100 characters, hit Write + Verify, then Read Bytes. The demo prints the same payload as hex and as UTF-8. In the game, that payload is whatever they pack: a weapon id, a keybind string, a small binary blob. The example in the demo is weapon=laser;move_up=W. The addon does not know it is a loadout.
Systems
User memory starts at NTAG page 4. Each page is 4 bytes. A 100-byte cap keeps a loadout inside the tag with room to spare, which matched their estimate (they expected well under 100, with no named presets). open() reads page 3 and requires the capability container E1 10 3E 00, the writable NTAG215 profile. A different card fails as TAG_PROFILE_MISMATCH and nothing is written. write_bytes pads only the last partial page by reading what is already there, writes pages 4 through 28 at most, then reads each page back. A mismatch is VERIFY_FAILED. Bytes after the payload are left as they were. A write is not atomic: if the card leaves mid-call, the recovery is to open again and write the full payload again.
Under the hood
NfcReader is a RefCounted Godot class. It forwards to NfcController, which owns the session, then to Ntag215Device, which speaks pages. WinPcscTransport is the only file that calls WinSCard. ReaderProfile encodes the APDU map this reader family uses: FF CA for the UID, FF B0 to read a page, FF D6 to write a page. The public method is open(), because connect() already belongs to Object. card_error is emitted once after a failed call. get_last_error_code() is what gameplay should branch on. The message string is for the demo log. The 15 native tests script a fake transport: NTAG215 acceptance, a rejected capacity, exact read length, partial-page preserve, verify mismatch, transaction release on a failed read, UID length, multiple readers, and the PC/SC status words for card removed, reset, reader removed, and a protected tag (6982). Godot never has to be open for those.
Controls
How it came together
Scope
Windows-only GDExtension. Raw bytes on the client’s own NTAG215 cards. GDScript owns the loadout layout. Error signals for a missing card, a pull mid-write, and a failed write. MIT, source plus DLL.
Demo and protocol
Demo UI with a status line for every failure. Read and write go through one PC/SC transaction per call. Protocol tests run against a simulated reader, so the byte rules were proven before a card was on the desk.
Handover
Godot 4.6 project, addon, README, and a demo scene: refresh readers, open the card, write 1–100 characters, read the hex and UTF-8 back.
On their hardware
The client plugged in their reader and an NTAG215. Open, write, and read matched. They accepted it and left a vouch.
What I would do differently
Keeping the schema out of the addon is what made the commission small and safe. I never needed their loadout struct, and they can change it without a new DLL. The simulated transport is what let the protocol ship before a reader was available. Their hardware pass is the part I could not sign off myself, and it passed: same demo, their NTAG215, no issues. The command map is one reader profile. A driver that does not answer those three APDUs needs its own ReaderProfile, written down from the driver docs, not discovered by writing to unknown pages.
What the client said
Works really efficiently, with good communication and the end result worked as intended. Would commission again