# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this is A Docker image definition for headless DOS/SCUMM games exposed over noVNC (VNC-over-websockets in the browser), with game audio also carried to the browser over a second, separate WebSocket (VNC/noVNC itself only ever streams video). It builds on the public base image `ich777/novnc-baseimage`, which already provides TurboVNC, websockify, fluxbox, and noVNC — this repo adds `dosbox`/`scummvm`/`alsa-utils` (game runtimes), `pulseaudio`/`pulseaudio-utils`/`libasound2-plugins` (audio), plus `unzip`/`smbclient`/`python3` for game management, a TLS cert for noVNC, and the entrypoint/lifecycle scripts. Published as `jayfield/dosbox-novnc` on Docker Hub. `scummvm`'s binary is at `/usr/games/scummvm`, not `/usr/bin` (Debian's convention for game packages) and not reliably on `$PATH` under a non-login shell — manifests reference it by absolute path. Game data (game installs, sourced as zips from an internal SMB share `//vlda-01/software/`) is deliberately **not** baked into the image — one of the games (`t7g`, "The 7th Guest") is ~930MB of CD-ROM ISOs on its own, more than the rest of the image combined. Instead the image ships empty and games are installed at runtime into a volume, via an HTML setup page (see below). ## Commands There is no build system, linter, or test suite — this is a Dockerfile plus a handful of shell scripts and a small Python HTTP server. Common workflows: ```bash # Build the image docker build -t jayfield/dosbox-novnc . # Run it the way it's intended to be run (fixed name, volume, ports) ./run.sh # Launch a specific installed game inside the running container ./game.sh # e.g. ./game.sh stuntcar ``` There's no `create.sh` in this project (unlike the other noVNC-family projects) — `run.sh` is the only launch script, and it's `-d --rm` (long-lived-but-disposable), not a persistent named container created once via `docker create`. There's no automated way to exercise `scripts/server.sh`, `scripts/setup_server.py`, or `scripts/start_game.sh` outside a running container — to test changes, rebuild the image, start a real container, and either watch `docker logs` or `docker exec` in. ## Architecture **Image layering** (`Dockerfile`): - Base image supplies TurboVNC (`Xvnc` on `$PATH`), websockify (`websockify` on `$PATH`), and noVNC's web client at `/usr/share/novnc/`. - This layer adds `dosbox`/`scummvm`/`alsa-utils` (game runtimes), `pulseaudio`/`pulseaudio-utils`/`libasound2-plugins` (audio, see below), and `unzip`/`smbclient`/`python3` (needed permanently at *runtime* now, not just at build time — see below), writes `/etc/asound.conf`, generates a self-signed TLS cert/key at build time (`/tmp/novnc.pem`, `/tmp/novnc.key`) for noVNC's HTTPS/WSS listener, and copies `scripts/` to `/opt/scripts/`. - `${GAMES_HOME}` (`/opt/games`) is created and declared as a `VOLUME` — it's meant to be backed by a named volume at `docker run` time (`run.sh` uses `-v dosbox-games:/opt/games`) so installed games survive `--rm` container restarts instead of being re-fetched every time. - `ENTRYPOINT` is `/opt/scripts/server_start.sh /opt/scripts/server.sh` — the signal-handling wrapper invoked with the real startup script as its argument. **Runtime env vars** (set at `docker run` time, not baked into the image): `DISPLAY_NUM` (only used to compute the setup port, see below — default 99), `SCREEN_W`, `SCREEN_H` (framebuffer geometry, shared by every game's Xvnc instance). There is **no single shared desktop/display for video** — unlike the other noVNC-family projects, this one doesn't run one always-on `Xvnc`+`fluxbox`+`websockify` trio for the whole container lifetime. `server.sh` starts PulseAudio and the audio bridge (see below) once, then `exec`s nothing but `setup_server.py`; every game gets its own ephemeral X session, created when it's started and torn down when it's stopped (see below). Audio, in contrast, *is* shared for the container's whole lifetime — see the Audio section below. **Port scheme**: - `70${DISPLAY_NUM}` — the game **setup UI** (`scripts/setup_server.py`), plain HTTP. `run.sh` publishes this as `7099:7099`. - `71${DISPLAY_NUM}` — the shared **audio WebSocket** (`AUDIO_PORT`, `7199` by default) — see Audio below. `run.sh` publishes this as `7199:7199`. - `5990`-`5999` — per-game raw VNC (RFB), one port per concurrent game slot, not published (matches the "RFB stays localhost-only" convention elsewhere in the noVNC family). - `8090`-`8099` — per-game noVNC over HTTPS/WSS, one port per concurrent game slot (`MAX_CONCURRENT_GAMES = 10` in `setup_server.py`). `run.sh` publishes the whole range (`-p 8090-8099:8090-8099`) up front, since Docker can't add port mappings to an already-running container — a game's actual assigned port within that range is only known once it's started. - `60${DISPLAY_NUM}` — `EXPOSE`d for consistency with the other noVNC-family projects, but nothing wires up X11-over-TCP here (no `socat` call) — this project doesn't do that, unlike `docker-xserver-novnc`/`docker-sdr-novnc`. `run.sh` hardcodes `DISPLAY_NUM=99` (implicitly, via the image default), only relevant to the `7099` setup port; update it if `DISPLAY_NUM` ever changes. **Game installation & lifecycle** (`scripts/setup_server.py`), the key thing that differs from the other noVNC-family projects: - A stdlib-only Python `ThreadingHTTPServer` (no third-party deps, no pip installs) — the *only* thing `server.sh` starts. No Xvnc/fluxbox/websockify run until a game is actually started. - Games are **not** hardcoded anywhere in the image or discovered from raw zip files. Each game is described by its own `.json` manifest living in the `Games\dosbox\` catalog directory on the SMB share, with `name`/`title`/`zip_path`/`release_date`/`publisher`/`programs` fields — see the module docstring in `setup_server.py` for the exact schema. `discover_games()` lists `*.json` under `Games\dosbox\` (`smbclient -N ... -c 'ls Games\dosbox\*.json'`), `smbclient get`s each one into `/tmp/manifests/`, and parses it — so dropping a new `.json` on the share is enough to make a game appear, no image rebuild needed. `zip_path` is a full path relative to the share root (e.g. `Games/Monkey Island/The Secret of Monkey Island.zip`), **not** assumed to live next to its manifest — manifests always live in the `Games\dosbox\` catalog regardless of where the actual game data sits on the share (the SCUMM games' zips live in their own folders, e.g. `Games\Monkey Island\`, `Games\Indiana Jones\`). `release_date`/`publisher` are static declared metadata (historical facts, can't be derived from the share); install size deliberately is **not** a manifest field — `get_zip_sizes()` looks it up live via a batched `smbclient ls` (one `ls ""` per game in a single connection, results split on each command's `N blocks ... available` trailer) so it can't go stale if a zip is replaced. - `programs` is a **list**, not a single command, so a game can expose more than one runnable binary — e.g. `duke`/`dn3d` list both `play` (`duke3d.exe`) and `setup` (`setup.exe`, DOSBox/Build-engine's interactive hardware-configuration wizard, distinct from the game itself). Every manifest on the share has at least one entry (most just have a single `{"id": "start", "label": "Start", ...}`, preserving the plain "Start" button they had before this existed). Each entry gets its own button in the "Installed" row (`doAction('start', name, program_id)`); `POST /start` reads the `program` form field and `start_game()` looks up the matching entry by `id`, using its `cmd` in place of the old single `start_cmd`. Still only one program per game slot at a time — starting a second program while the first is running is refused the same way restarting an already-running game is (`is_running()` doesn't care which program is active, just whether the tracked process is alive). The active program's `label` is stashed in `running_procs[name]["program_label"]` purely for display, so the "Running" status can show e.g. "Running (Setup)" instead of just "Running" now that the game name alone doesn't imply which binary is active. - `GET /` renders one row per discovered manifest (title/release/publisher/size/status/actions) plus a `"{used}/{MAX_CONCURRENT_GAMES} game slots in use"` line at the top. **None of the action buttons are `
`s** — every one is a bare `