CASE · 072025 — ONGOINGSOLOSELF-HOSTED · SSE · ENRICHMENT
◦ LUDOTEK · self-hosted · docker

Your ROMs, remotely scanned. Every title, enriched.

Central web library for retro-gaming collections: scans Steam Deck, Retroid Pocket, and NAS remotely over SSH/FTP/local, normalises ROM filenames, enriches against IGDB + SteamGridDB + OpenRouter AI. Next.js 14, SQLite, Docker. Enrichment streams live to the browser via SSE, scan runs as a background job with 2s polling.

§ 01Problem · motivation

Why this exists.

Retro ROM collections grow scattered across devices. No existing solution covers remote scan + metadata + UI in one place.

Retro-gaming enthusiasts spread their ROM collections across multiple devices — Steam Deck, Retroid Pocket, NAS, local drives — and quickly lose the overview. Filenames like Super Mario 64 (USA).z64 say nothing about release year, genre, developer, or cover art.

Existing solutions are either pure frontends for individual devices (EmulationStation, ES-DE) or desktop tools without remote scanning and without a curated metadata pipeline. Ludotek closes that gap: a central web hub that scans devices remotely, enriches ROMs automatically, and presents the collection as a searchable, visually curated library — even when the target device is currently offline.

§ 02Constraints · operating box

The box it had to fit in.

Hobbyist-first: no DevOps background, no cloud subscription, no config files.
C/01 · HOSTING
Self-hosted first. One-command Docker deploy with persistent volumes, no cloud dependency.
C/02 · DATABASE
SQLite + Prisma 6.x, so hobbyists can start with zero DB setup — no Prisma 7 because of the incompatible config.
C/03 · RATE LIMITS
IGDB and SteamGridDB force sequential processing with a 500ms delay and exponential backoff on 429/5xx.
C/04 · REMOTE PROTO
Heterogeneous: user picks SSH, FTP, or local — defaults per device type, because Steam Deck speaks SSH but many Android handhelds only expose FTP.
C/05 · PLATFORM COV
66 platform conventions. ES-DE puts ROMs in xbox/roms/, others keep them flat — recursive scan with subdir awareness.
C/06 · SECRETS
Device passwords + API keys are AES-256-GCM encrypted on disk, key from ENV or auto-generated.
C/07 · LONG OPS
Enrichment for 2,000+ games must not time out in the browser — SSE streaming instead of REST polling.
C/08 · UX FLOOR
No config-file editing. Setup wizard in 5 steps. The audience is not a DevOps crowd.
§ 03Architecture · dual long-running pipelines

How it runs.

Two long-running pipelines: enrichment streams progress events back to the browser via SSE, scan runs as a background job with 2s polling against /api/scan/status. Two React contexts channel both streams into UI state.
pipeline.live·devices 3·roms 2,147·enriched 1,892·missed 56
sse connected·igdb ms
INPUT · 01
Remote hosts
steam-deck · retroid · nas
online3 / 3
PROTO · 02
SSH · FTP · local
test-connection · 10s timeout
reconnects12
SCANNER · 03
Recursive walk
66 platform conventions
STORE · 04
SQLite · Prisma 6
Game · Device · Cover
rows2,147
CLEAN · 05
Filename normalize
strip (USA) · (Rev A) · [!]
guardFIFA 98 ✓
MATCHER · 06
searchIgdb() · 3-stage
platform → cross → split
PLATCROSSSPLIT
FETCH · 07
IGDB · SGDB · AI
500ms delay · backoff
igdbsgdbopenrouter
CACHE · 08
/api/cache mirror
data/ · offline-ok
fill88%
SECRETS · 09
AES-256-GCM
env · file · auto-gen key
vaulton disk
WIZARD · 10
Setup · 5 steps
no config-file · proto preset
first-runguided
SSE · 11
/api/enrich stream
progress · enriched · done
scan48%
LIBRARY · 12
Next.js 14 UI
search · filter · discover
visible1,892
EVENT LOG · /api/scan/status (poll) · /api/enrich (sse)
12:04:18scanprogress 1,247/2,147 · nintendo-switch
12:04:17enrichmatched "Project Justice" → igdb:2583
12:04:16enrichcover cached: data/covers/co1a2b.jpg
12:04:14scanfound 14 roms · xbox/roms/
12:04:11enrichai: fun-fact generated · Skies of Arcadia
12:04:08scanpoll /api/scan/status · psp
12:04:05cachemirror fill 88% · 1,892 covers
12:04:02enrichmatched "Wind Waker" → igdb:1033
12:03:58secretsaes-256-gcm · device:steam-deck unlocked
12:04:18scanprogress 1,247/2,147 · nintendo-switch
12:04:17enrichmatched "Project Justice" → igdb:2583
12:04:16enrichcover cached: data/covers/co1a2b.jpg
12:04:14scanfound 14 roms · xbox/roms/
12:04:11enrichai: fun-fact generated · Skies of Arcadia
12:04:08scanpoll /api/scan/status · psp
12:04:05cachemirror fill 88% · 1,892 covers
12:04:02enrichmatched "Wind Waker" → igdb:1033
12:03:58secretsaes-256-gcm · device:steam-deck unlocked
§ 04Decisions · trade-offs

Four deliberate choices.

Per decision: what was chosen, instead of what, why.
D/01

SSE streaming for enrichment, background polling for scan.

chosen
Enrichment: Server-Sent Events with progress/enriched/missed/error/done. Scan: fire-and-forget POST + 2s polling on /api/scan/status.
instead of
Both pipelines on the same mechanic (either both SSE or both polling)
reason
Enrichment for 2,000+ games takes 20–30 minutes and produces an event per title that needs to land live — REST polling would have lied to the browser (snapshot granularity only) and every 504 timeout would have lost state. SSE pushes real progress: every matched title shows up the moment it lands. Scan, on the other hand, produces only a handful of status updates per path, runs entirely server-side, and survives a browser reload — a slim status endpoint with 2s polling does the job. Different loads, different mechanics.
D/02

SQLite + Prisma 6 instead of Postgres.

chosen
SQLite file in data/ · Prisma 6.x (not 7)
instead of
Postgres container as a Docker Compose dependency
reason
The audience is hobbyists. Anyone who sets up a Postgres container can compose without my help. The goal was: one docker compose up -d and it runs. SQLite scales comfortably to 10K games per instance, and the backup is a file copy. Prisma 7 was ruled out because of incompatible config breaking changes — 6.x stays the stable choice.
D/03

Three protocols, preselected per device type.

chosen
ssh · ftp · local — defaults per device type (Steam Deck → SSH, Android → FTP, Local PC → filesystem)
instead of
One protocol with automatic-fallback magic underneath
reason
The device ecosystem is too heterogeneous for a single default: Steam Deck speaks SSH out of the box, many Android handhelds (Retroid, Odin) expose their built-in FTP server, NAS mounts run purely over the local filesystem. The setup wizard preselects the right protocol based on the chosen device type — users can override everything in custom devices. A dedicated test-connection route with a 10s timeout catches misconfigurations before the first scan, instead of letting them surface as a "hanging scan".
D/04

Field-level encryption for secrets on disk.

chosen
AES-256-GCM · getDecryptedDevice() wrapper · auto-migration from plaintext
instead of
Plaintext in SQLite, or an OS-keyring dependency
reason
Device passwords in plaintext inside data/ludotek.db is a no-go for a self-hosted tool that users drop into home-lab networks without a second thought. The OS keyring is out — it doesn't work inside a Docker container. AES-GCM with the key sourced from ENV > data/.encryption-key > auto-gen delivers safe defaults AND an override path for power users — the order matters, so that an ENV key can't resurrect an old on-disk secret.
§ 05Highlights · interesting bits

Things that were not obvious.

Edge cases and details that only became clear once the build was underway.

Three-stage IGDB matcher

H/01
searchIgdb() runs three searches in sequence: platform-specificcross-platform hyphen-split fallback. The last stage is the reason "Project Justice - Rival Schools 2" gets found at all when IGDB only knows the title as "Project Justice" — the split takes the part before the hyphen as the fallback query.

A naive single-query matcher had a ~18% miss rate on a 2,147-game collection. With the three-stage fallback: under 3%. The remaining misses are homebrew and fan translations that IGDB simply doesn't list.

Per-game CacheEntry instead of URL-keyed map

H/02
Two games with the same IGDB cover hash used to corrupt the cache state of other games — a URL-keyed shared map would overwrite Game A's metadata with Game B's load progress because both pointed at the same cover URL.

Fix: per-game CacheEntry instead of a URL-keyed map. Every game holds its own cache state, even when the asset is deduplicated. Ground truth: when two entries share a hash, that's an asset optimisation, not state sharing. (Commit 2839055)

ROM filename cleaning without overcorrection

H/03
Region tags (USA), revisions (Rev A), dump flags [!], multi-disc suffixes all need to go — but not too aggressively.

A regex bug stripped every trailing number and silently turned "FIFA 98" into "FIFA". IGDB then matched a completely different game. Fix: clean only on parenthesised suffix patterns, never on standalone year/version tokens. Plus: multi-disc dedup only fires when the base title is identical — "Disc 2" alone is not a merge signal.

Theme switch without chart flash

H/04
Recharts renders stroke/fill once on mount. Hardcoded hex values don't survive a theme toggle — the charts stayed on light colours for ten seconds after a dark-mode switch, until the React subtree remounted.

Fix: CSS variables as RGB channels (--vault-amber: 217 119 6) instead of prebuilt rgb() strings, then rgb(var(--vault-border)) in the chart props. Theme switch rebinds the variables, CSS rebinds the charts. Bonus: Tailwind opacity modifiers (/50) work automatically on top.

Prisma binary targets in Docker multi-arch

H/05
Multi-arch (linux/amd64 + linux/arm64) on node:20-slim needed explicit binaryTargets in schema.prisma — otherwise the Prisma client crashes on the first query on ARM devices with "Query engine binary not found".

The build went green locally (amd64), CI pushed the image, and users on Raspberry Pi / Apple Silicon crashed immediately. Fix: every realistic runtime target in one generator: ["native", "debian-openssl-3.0.x", "linux-arm64-openssl-3.0.x", "linux-musl-openssl-3.0.x", "linux-musl-arm64-openssl-3.0.x"]. (Commit cc58f7b.) One of those failures you would only have caught with CI matrix builds against real ARM targets.

Dual auth for browser + API clients

H/06
ADMIN_TOKEN unset = open dev instance; set = Bearer header or admin_token cookie accepted. Browser navigation drops a cookie, API clients send the header — both hit the same routes.

No forced login flow for single-user home labs, but an upgrade path the moment the instance becomes publicly reachable. One env var turns an open tool into an authenticated app — no code change, no UI migration.
§ 06Stack · in production

What's running.

Working toolchain — nothing theoretical.
Next.js 14 · App RouterTypeScript 5Prisma 6 · SQLiteTailwind 3.4 · custom UIRecharts (theme-aware)SSE · React Contextssh2 + basic-ftpIGDB + SteamGridDBOpenRouter · Gemini 2.5AES-256-GCMDocker · Multi-ArchVitest · PlaywrightSetup Wizard · 5 steps
§ 07Reflection · takeaways

What I learned.

The project ships as a Docker image. These are the lessons I'm carrying forward.

Self-hosted is a UX constraint, not a deployment detail.

I started with "runs in Docker", and that felt like the whole job. Reality: for a tool that hobbyists drop into their home network, every config file, every ENV var without a default, every error message without a recovery path is a UX failure. The setup wizard and the auto-migration of plaintext secrets came out of that — not because they were cool, but because the alternative ("read the README and edit YAML") was not acceptable. Self-hosted means: the first 60-second impression has to be as polished as Supabase or Vercel, just without their DevRel team behind you.

SSE is underrated.

WebSockets always get recommended, but for unidirectional server push, SSE is lighter, proxy-friendlier, and done with a single event handler. No protocol-upgrade dance, no heartbeats, no reconnect code. For long-running ops with progress events, SSE is the right default — WebSockets would have been over-engineering here and would have turned routing through reverse proxies in a home lab into its own problem.

◦ NEXT CASE · 08 / 11
AgoraHub
← all projects