Android TV · LG webOS · Windows2026In development — v1.0 built for Google Play

CouchDeck

Your gaming PC on the TV, as a console. No desktop, no launchers, no PIN to type.

CouchDeck on a living-room TV: the Home screen with a game hero and a row of covers, the focus ring moving, then a game starting.
The native TV client on the desktop stand with real Steam art: Home, the library, back, then a launch. The launch is a cut to gameplay — the stand cannot pair with a PC — and the room is generated.
445
commits in three weeks, three repositories
3
platforms, 4 languages
~1,200
tests across three suites
0
digits typed to pair

At a glance

  • One library across stores Steam and Epic from on-disk manifests; GOG in the code
  • The curtain Four events; the desktop never reaches the TV
  • Zero-digit pairing Agent confirms the PIN inside a guarded window
  • One-click Windows installer Bundled Node + Apollo, no console windows, under a minute
  • Native on both TVs Kotlin/Compose on Android TV, C/LVGL on webOS
  • Three test suites + mutation testing 527 Node, 451 Android, 221 ctest

The problem

Four foreign screens before a game

Moonlight and Sunshine already stream a gaming PC to a TV, for free and well. The trouble is everything around the stream. The flow that started this project crossed four foreign screens before a game appeared: the Moonlight splash, its UI, its connect screen, our own splash — and then a flash of the Windows desktop. You typed a PIN on the TV. You picked the game from a list that only knew about Steam. And when you quit, you landed on a desktop with a mouse cursor you couldn't move.

CouchDeck is for someone who already owns the PC and the TV and wants it to feel like a console: turn on the TV, open one app, pick up the gamepad, see every game in one library, press a button, play.

Moonlight splash Moonlight hosts & apps Connect screen Our splash Windows desktop flash Game Home every game, one library Curtain until the window exists Game press A Moonlight, before CouchDeck A PIN typed on the TV. A game list that only knew Steam. Quitting left a desktop and a cursor you could not move.
Every dashed box is a screen that belongs to someone else. The red one is the Windows desktop.

What I built

Three pieces on three machines

A zero-dependency Node agent on the PC scans the Steam, Epic and GOG libraries from their manifests on disk, fetches cover art, talks to Apollo (the Sunshine fork that does the actual streaming), and exposes a small HTTP API. A Windows tray app and a one-click installer wrap it — bundled portable Node, bundled Apollo, generated credentials, autostart, uninstaller, no console windows, "double-click and wait about a minute". On the TV, a Kotlin/Compose client forked from moonlight-android and a C/LVGL client forked from moonlight-tv for webOS, both built from one Figma file I drew at the TV's exact 1920×1080.

Gaming PC · Windows TV Tray app + one-click installer C# · NSIS · PowerShell — bundled Node, bundled Apollo Agent Node, zero dependencies library · covers · launch pairing · HTTP API Steam · Epic · GOG on-disk manifests, no store APIs Apollo Sunshine fork — the stream itself The game watched by its window Android TV Kotlin · Compose fork of moonlight-android v1.0 — the release target LG webOS C · LVGL fork of moonlight-tv personal build confirm PIN run wrapper stream HTTP: library, launch, pairing
The clients never talk to Steam or to Windows. They ask the agent; the agent runs the game through a wrapper that reports what the window is doing; Apollo only streams.
The CouchDeck Home screen on the webOS client: TIM-PC online at the top left, the navigation bar, a clock, Cyberpunk 2077's key art filling the screen, a row of covers with the focus ring on Cyberpunk 2077, and button hints at the bottom right.
Home, on the webOS client at 1:1, with real Steam art. It opens on the last played game; cards animate position and size, the hero crossfades after 300 ms of rest.
The CouchDeck Library: fourteen games in a grid of covers, each with its store badge, Steam or Epic Games, a filter for All, Steam and Epic Games, and the focus ring on Cyberpunk 2077.
The library: one grid for every store, each cover badged with where it came from. RB and LB move between tabs, as on a console.

Pairing is automatic. The client invents a PIN, starts pairing with Apollo, and posts the same PIN to the agent, which confirms it on your behalf and grants full permissions. No digits ever appear on screen.

Since the first end-to-end run the Android client has grown the rest of a console: quality presets picked from a link test, with a "looks bad" report from the overlay; Wake-on-LAN; pairing a gamepad from inside the app; an alphabet jump through a long library; UI sounds and press feedback.

Design decisions

A console, not a remote desktop

The curtain

The stream starts under an opaque curtain and the client only lifts it when the game window actually exists. The agent's run wrapper emits four events — starting, running, closing, exited — and the curtain follows them. closing exists because Battlefield 6 keeps its process alive for 70 seconds after its window disappears, streaming the desktop the whole time.

curtain the game curtain — never the desktop Process Window The TV sees starting running closing exited Battlefield 6: 70 s of process after its window

One ring, two shapes

The focus ring is one component that morphs between a 104×60 nav pill and a 1016×112 settings row. Keying it by shape and size makes the old ring die in place and the new one emerge 32 px behind the direction of travel. I verified it by slowing animations from 200 ms to 3 s and diffing adb screenshots pixel by pixel — screenrecord captures six frames in three seconds and can't see motion at all.

Home with the focus ring as a pill around the Home tab in the navigation bar.
Settings with three rows — Change PC, Refresh library, Sleep PC — and the focus ring around Refresh library.

The ring's two shapes — the nav pill and a settings row — shown on the webOS build. On Android TV one ring morphs between them.

Darker than Figma

The hero scrim was tuned on the real TV so a 64 px white title survives Cyberpunk's yellow key art — the hardest cover in the library, in the picture above. The nav icons are 32, not the mock's 36 — also decided on the TV. The spec's rule is that when the code and the document disagree, the document wins, so I changed the document to match the TV.

English only, on purpose

A Russian desktop path — 'Рабочий стол' — arrived at the installer as question marks after a trip through the system code page. Paths now travel in UTF-8 JSON, never on a command line, and the product is English-only with byte-level tests guarding it.

The hard parts

Where Windows, anti-cheat and TV hardware pushed back

Games that hide from the OS

Anti-cheat processes run elevated, so their path is unreadable and any filter by install directory silently misses them: FragPunk reported running 21 seconds late and exited 96 seconds late, and closing never. The fix identifies the game by window, not process — snapshot windows before launch; the one that appears after and survives two polls is the game.

Explorer Steam Apollo tray Explorer Steam Apollo tray FragPunk new, and still there snapshot, launch, diff Windows before launch After launch, two polls later By process path: elevated, unreadable — FragPunk reported running 21 s late, exited 96 s late, closing never. By window: the one that appears after launch and survives two polls.
Schematic. The window list is illustrative; the timings are FragPunk's, measured.

Apollo's permission race

Apollo grants full rights only to the first paired device; everyone after gets read-only and fails at launch with a 403. The agent diffs the client list before and after PIN confirmation — but the confirm call returns mid-handshake, so a single request always sees an empty diff. Polling every 250 ms for up to four seconds, bounded by the client's six-second timeout, takes the first non-empty diff.

A pairing window that can't be stolen

Once PIN confirmation moved from human to agent, any device on the LAN could pair itself forever. The window opens only by a human action on the PC, caps at ten minutes, closes on first success — and runs on a monotonic clock, because Windows corrects the system clock right after login and a backwards step used to extend the window.

TV client Agent Apollo poll the client list every 250 ms, up to 4 s start pairing with an invented PIN the same PIN confirm PIN — returns mid-handshake first non-empty diff: grant full rights paired — no digits on screen The window opens only by a human action on the PC, lasts ten minutes at most on a monotonic clock, and closes on the first success.
Both fixes in one sequence: the orange poll is the race, the rule under it is the window.

LVGL on a TV CPU

Three live iterations: 20–22 fps, then 7–12 after adding per-frame scaling, then smooth. The rule that fell out: no zoom transforms, no full-screen alpha blends in animations. Bake everything into textures in workers — rounded corners and scrims included. The focus ring is seven pre-rendered PNGs, one per size.

The ring leaving Cyberpunk 2077.
The ring between the two covers.
The ring almost on Elden Ring.

The ring flying from Cyberpunk 2077 to Elden Ring on the webOS build: three frames of one 200 ms move. A pre-rendered texture slides; nothing is scaled per frame.

Building for two foreign platforms from a Mac

Node's single-executable builds can't do ESM, so the agent ships as portable Node plus files. Homebrew's makensis dies with bad_alloc; the one in a Debian container builds a real PE32. The webOS .ipk cross-builds in Docker in about nine seconds. A Windows box is now needed only for the final live install check.

How it was built

Three test suites and a register of rough edges

257 commits in the agent repository between 23 August and 13 September, plus 188 across the two client forks. Three independent test suites — 527 Node tests, 451 on the JVM, 221 ctest cases — and mutation testing on every task (breaking the code on purpose to check the tests notice), which caught two real defects that 380 tests had missed. I built it with Claude Code agents; CLAUDE.md holds the hard rules (no npm dependencies, don't touch the event contract); the specs, plans and session hand-offs live in the repo as project memory, including a "known rough edges" register that is deliberately not fixed in passing.

Where it stands: v1.0 is built and signed for Google Play, and closed testing is next. The first install on someone else's PC found three bugs in the installer; all three are fixed and wait for the next live run. webOS stays a personal build.

Commits a day, three repositories — 445 23 Aug 30 71 3 Sep 11 v1.0 13
445 commits across the agent and both forks; the webOS fork starts on 26 August, the Android one on the 30th. The quiet stretch after 5 September is the move from building to releasing: the Play build, the store listing, the developer account.

Details

Role
Product, design, engineering — solo
Stack
KotlinJetpack ComposeNodeC#CLVGLSDLNSISPowerShellApollo/Moonlight
Timeline
  1. 23 Aug · First commit
  2. 26 Aug · webOS fork; the curtain confirmed on the live TV
  3. 30 Aug · Android fork
  4. 3 Sep · Android TV chosen as the v1 platform
  5. 5 Sep · Quality presets, Wake-on-LAN, gamepad pairing, GOG
  6. 11 Sep · First install on someone else's PC
  7. 12 Sep · v1.0 signed for Google Play

Next