CrossPlay

2026-08-07

Back

An e-ink panel holds its image with the power off. That is the whole trick, and it is why a page can sit there indefinitely costing nothing. Nothing about the screen is urgent. It is also the thing that makes e-ink useless for almost everything a screen normally does, so the entire industry has spent that property on one activity, which is books.

A chess position holds still too. So does a flashcard, a logic grid, an article you saved to finish. In all of them the screen puts up a situation, you think, and then you decide, and for almost the whole activity nothing needs to move. A panel that cannot animate is not a compromised console for that kind of game. It is the right one.

The hardware is an Xteink X4 Pro, a cheap e-reader built around an ESP32-S3. CrossPoint is Dave Allie's replacement firmware for it, MIT licensed, and it is a genuinely good e-reader. CrossPlay is my fork. It keeps the reading side and adds the rest: chess, a Klondike tableau turned sideways, the daily Connections grid, Anki decks on the FSRS scheduler, the Hacker News front page, the xkcd archive. Upstream lands on a base branch and gets merged in; the apps live in src/apps_local/, which is what keeps that merge close to conflict-free.

The one rule

The black you can afford is inversely proportional to how often it changes.

A solid black header that never repaints is free. E-ink holds it at no power and it cannot ghost, because ghosting is a residue of change rather than of darkness. The identical rectangle of black inside a play surface that repaints every move ghosts badly and drags the refresh out. The standard advice you find in every e-ink design note, borders over fills, is right about content and wrong about chrome. The fills are fine. The fills that move are not.

The refresh flash inverts the whole panel for a beat, and it is normally treated as something to hide. Spent deliberately it is a page turn. CrossPlay uses a partial refresh for a chess move and a full refresh for a new game or a checkmate, so the flash lands on the two moments that deserve punctuation, and it costs nothing, because it was going to happen anyway.

Two devices, nothing to type

The feature I care most about has the least code in it. Put two of these next to each other and they find one another: nothing to type, no account, no router, no internet. Underneath it is ESP-NOW, a connectionless link-layer protocol on the Wi-Fi radio the S3 already has, which broadcasts to find a peer and unicasts once it has one. The design brief was one sentence. What would the DS do. The DS never showed you a networking detail, so neither does this.

linkplay::Radio has three implementations behind one interface. On the device it is ESP-NOW. On my Mac it is UDP on loopback, which is how two simulator windows play each other. In a browser it is a JavaScript router, because two instances of a WebAssembly module are two separate memories and the only thing that can carry bytes between them is the page that owns them both.

Who moves first is a coin toss that rides in every packet, and I want to flag that because I got it wrong in an earlier draft of this piece and in a source comment that is now fixed. The obvious rule is that the lower address hosts. It is deterministic, it needs no extra state, and it is wrong: over two fixed MAC addresses it means the same device plays White in every game of chess ever played on that pair. So both sides compute the winner from the two tosses and the address only breaks a tie. Making the browser's slot numbers line up with the loopback ones was not cosmetic, because the tiebreak is the same code path in all three transports, and a bug in one reproduces in the others.

The firmware runs in the page

The site runs the actual firmware in the browser. Same src/ and lib/ the device build compiles, put through em++ instead of g++ with two files swapped out, against a seeded SD card of its own. You tap the screenshot and it becomes the device.

Getting there took three days of being wrong. The first version kept SDL and went looking for an Emscripten flag that would let the simulator's while (!display.shouldQuit()) loop survive a browser. PROXY_TO_PTHREAD puts main on a worker and leaves the canvas on the wrong thread. OFFSCREENCANVAS_SUPPORT then breaks the runtime's own main-thread getContext. OFFSCREEN_FRAMEBUFFER trips an assert inside the proxying queue. ASYNCIFY actually booted, and then drew every screen as a fragment.

The answer was not another flag. CrossMux's browser build had already solved this by not using SDL at all, and I took the design wholesale: the page owns the canvas, the module exports a pointer to the composited framebuffer and a dirty flag, JavaScript blits it, and main() returns immediately with the firmware running on a worker. Twenty-three SDL functions get answered with plain memory. No GL, no Asyncify, no event loop surgery, and nothing in src/ changes.

The fragments turned out not to be Asyncify's fault. My Frame and Screen wrappers took their device context and theme by const reference and every call site was passing a temporary, so the reference dangled the moment the constructor returned. That is undefined behaviour on the device too, and it had been undefined behaviour for a year. It read correct the whole time because the dead stack slot still happened to hold the right bytes. Compiled for wasm32 the next call reused that slot, Screen::contentRect() came back (60,21 0x0), and every game drew its board off the top of the panel. The fix is two one-member structs and a change to the order the base classes are listed in, because a base is initialised before the ones after it.

What I cannot claim

Everything above about ghosting is reasoned, not measured. The simulator is a clean 1-bit blit with no ghosting model in it at all, and every screenshot on the site and in this piece came out of that simulator. So the single claim the design leans on hardest is the one I have never tested on a panel.

The browser build lies in three specific ways, which are documented rather than hidden. Hacker News and the Connections archive answer from response bodies I curled once and froze onto the card, so the network works about as well as a museum diorama. Study wants its headword font at 50pt, that cut is 5.5MB, and the card ships the 17pt file under both names, so the character you are shown is smaller than the device draws it. Sleep is switched off, because a browser tab has no power button to wake with.

The firmware image is 5.79MB in a 6.25MB slot, 92.6% full. That reads worse than it is, since the other 6.25MB slot is the landing pad for over-the-air updates and the 3.4MB filesystem partition is empty, because books, fonts and packs all live on the SD card. Reclaiming any of it needs a serial reflash rather than an update, which is free to do now and expensive once a device is in someone else's hands.

A move takes as long as it takes. The panel is slow enough that it would be intolerable in anything wanting my reflexes, and slow in a way that is invisible in something wanting my attention. That is the whole bet, and it is a narrow one. The apps that fit on this device are the ones that were already waiting for me to think.

Live at crossplay.ma-r-s.com, where you can use it without owning one. Source on GitHub.

CC BY-NC 4.0 © ma-r-s

?