TV Hack
Log 04 / 06

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.

By prutxviBuilt with Claude~6 min read

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:

The phone controller in browser mode: status 'Direct · 7 ms', a URL bar, a large touchpad and a type box.
Browser pad. Note the "Direct · 7 ms" meter.
The phone controller in Doom mode, landscape: a thumb area on the left; Menu, Exit, Open, Weapon and a large red Fire button on the right.
Doom layout. Thumb anywhere on the left to move.

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.

  Press → TV
Cloud
~80 ms
On the TV
~10 ms

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.

FromMessages
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?

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.