Phone remote
The TV remote is fine for menus and useless for Doom. So the TV shows a QR code, and any phone that scans it becomes the controller. No app to install.
Scan, and the phone becomes the remote
The launcher generates a private room: a random 20-character ID, stored on the TV. The QR code in the corner of the home screen (drawn in dots, of course) is a link to that room. Scan it, and the phone opens a controller page in its browser.
The page changes layout to match what's on the TV. The TV announces its screen (home, game, doom, browser…), and the phone redraws itself:
- Launcher and menus: a D-pad with OK and Back.
- Snake: a swipe pad.
- Doom (landscape): a floating joystick that appears wherever your thumb lands, a giant FIRE button, plus Open, Weapon, Menu and Exit. It vibrates on Android and keeps the screen awake.
- TV Browser+: a touchpad, a URL bar and a keyboard (log 05).


Version 1: via Singapore
The first version used a Cloudflare Worker with a Durable Object per room. The phone and the TV both connect to the room over a WebSocket, and the room passes messages between them. It worked from anywhere, it cost nothing on the free plan, and it was simple.
But every button press went phone → Cloudflare's data centre in Singapore → TV, about 80 ms one way. For menus that's fine. For Doom you can feel it.
Slowed down 20× so you can see it. Measured: the cloud relay at ~80 ms one way (first press after idle ~680 ms); the TV's own server at 4–12 ms round trip on the same Wi-Fi.
Version 2: a server on the TV itself
The fix was to stop leaving the house. Samsung lets a TV app ship with a background service, and that service runs Node.js. So the TV could host the controller page and the WebSocket itself, and button presses would cross only the living room.
The catch: the TV's Node.js is version 4.4.3, from 2016. No npm, no modern JavaScript, no WebSocket library. So Claude wrote the WebSocket protocol by hand: the handshake, and the binary framing.
// launcher/service/service.js (Node v4.4.3: ES5, new Buffer, no npm)
var GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
server.on('upgrade', function (req, socket) {
var key = req.headers['sec-websocket-key'];
var accept = crypto.createHash('sha1').update(key + GUID).digest('base64');
socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n' +
'Connection: Upgrade\r\nSec-WebSocket-Accept: ' + accept + '\r\n\r\n');
socket.setNoDelay(true); // don't batch tiny key presses
join(room, role, socket);
});
// incoming frames from the phone are masked: XOR with a 4-byte key
if (masked) {
var mask = buf.slice(off, off + 4);
for (var i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
}
The whole server is about 135 lines. Now presses go phone → TV over Wi-Fi in about 10 ms. The launcher still joins the cloud room too, as a backup, and Customize → Phone link switches which one the QR code points to.
Phone ── scans QR ──▶ http://<tv>:8090/r/<room> page served by the TV
Phone ◀═ WebSocket ═▶ TV service :8090 ◀═ WebSocket ═▶ launcher
~10 ms round trip on Wi-Fi
(backup) Phone ◀═▶ Cloudflare Worker + Durable Object room ◀═▶ launcher ~80 ms
The protocol
Both servers speak the same small JSON protocol, so the phone page doesn't care which one it's talking to.
| From | Messages |
|---|---|
| Phone → TV | {t:'k', c:keyCode, d:1|0} key down/up · {t:'cmd', v:'home'} · touchpad moves, taps, text and zoom for the browser |
| TV → phone | {t:'screen', s:'doom'} so the phone switches layout |
| Server → both | {t:'phones', n} how many phones are connected · {t:'tv', on} whether the TV is there |
Phones may only send an allow-listed set of message types, and messages are capped at 512 bytes on the TV and 8 KB on the cloud.
Who can control the TV?
- Only someone with the room link. A 20-character random ID is about 103 bits, so it can't be guessed. That's also why every QR code in this site's photos and videos is blurred.
- The endpoint that reveals the room only answers requests from the TV itself (
127.0.0.1). Anything else on the Wi-Fi gets a 403. - It controls only our launcher, Snake, Doom and TV Browser+ pages. Not Netflix, not YouTube, not the TV's settings.
What it costs
₹0. Cloudflare's free plan allows 100,000 requests a day, and incoming WebSocket messages count 20 to 1. An hour of Doom is about 1,000 requests, roughly 1% of the free tier. And with the on-TV server as the default, most presses never touch the cloud at all.