Doug's Game Dev Log
About
2026-08-29

The Game Left the Browser

TD Survivors is a browser game and will stay one, but it is also now a Windows and Linux desktop app, packaged automatically by CI on every push to main and published at tds.doug.pt/downloads. This went in as three tickets: make the bundle able to run outside a browser, wrap it in Electron, and get CI to build it without slowing the web deploy down. Zero gameplay changes across all three, and one embarrassing security fix at the end.

Ticket A: run outside the browser

The web build assumes it is served from a web server at a root path, and a desktop shell serving files over a custom protocol is neither of those. Four pieces of groundwork, all shipped before any Electron code existed:

  • Relative asset paths. A vite.config.ts with base: './' so dist/ references its assets relatively, plus dropping the leading slash from the favicon, font and cursor paths in index.html and src/cursor.ts. A custom app:// protocol resolves absolute paths in a way you do not want.
  • A Steam facade. src/lib/steam.ts is a typed no-op: isSteam, setRichPresence, openInviteDialog, cloudRead/cloudWrite, all reading an optional window.steam bridge that does not exist yet. Nothing in src/ imports Steam for real. The point is that the shape is decided now, once, instead of being invented in a hurry later.
  • A share link that knows where it is. lib/shareLink.ts is a pure function returning a full room URL on http/https origins and just the bare room code otherwise, wired into the lobby. An app:// origin was producing a link nobody could paste anywhere.
  • A frame driver. lib/frameDriver.ts arms a requestAnimationFrame and a timer together; whichever fires first drives the frame, with a done guard so you never get two. It is installed over window.requestAnimationFrame before engineInit, guarded by desktop detection, so a minimized desktop window keeps the simulation advancing while the web build stays on native rAF. Browsers throttling rAF in a background tab is correct behavior; a co-op host whose window is minimized stalling the whole session is not.

Ticket B: the Electron shell

The shell lives in desktop/ inside the project, deliberately, so the CI's existing "did td-survivors change" detection covers it without a new rule.

main.js registers a privileged app:// scheme before app-ready (standard, secure, supportFetchAPI, stream), then serves the packaged ../dist through protocol.handle with a path-traversal guard, using net.fetch so media streams and Content-Type is set correctly. The window is 1280x720 and hardened the usual way: context isolation on, sandbox on, node integration off, no application menu, backgroundColor #111111 with show-on-ready so there is no white flash on launch. Fullscreen stays available for the in-game F key.

Then a pile of flags, in two groups. Background: backgroundThrottling off plus disable-background-timer-throttling, disable-renderer-backgrounding, disable-backgrounding-occluded-windows and disabling CalculateNativeWinOcclusion, which is the other half of the frame driver work: an occluded or minimized window must keep simulating. Performance: ignore-gpu-blocklist, enable-gpu-rasterization, enable-zero-copy, canvas out-of-process rasterization, force-high-performance-gpu, and V8 code caching. A powerSaveBlocker prevents display sleep while the window lives, because watching your defense hold is a legitimate way to play a tower defense.

preload.js exposes window.steam through contextBridge as no-ops matching the Ticket A interface, which is the seam a real Steam bridge slots into later. electron-builder.yml produces a Windows zip (x64) and Linux AppImage plus tar.gz (x64), asar packed, max compression, pulling the game in from ../dist.

Ticket C: CI builds it in the background

Packaging Electron for two platforms takes minutes, and the web deploy takes seconds. Making the second wait on the first would be a bad trade forever.

So on a push to main that touches td-survivors/, deploy.js snapshots the tree with git archive and fires an electronuserland/builder:wine container with docker run -d, named caches, and a tee'd log. It does not wait. The web deploy finishes and the desktop build lands whenever it lands, which is exactly the right priority.

Inside, desktop/ci-build.sh builds the web bundle, runs electron-builder for both targets, publishes artifacts as td-survivors-<platform>-<date>-<sha8>.<ext>, regenerates an index page, and prunes to the five most recent builds. The game's nginx config got a location /downloads/ block (autoindex on, try_files to a 404) placed before the SPA fallback, otherwise a missing file would helpfully return the game's HTML instead of a 404, which breaks download managers in confusing ways.

And then it served the source code

The first version wrote artifacts to /ci/artifacts/td-survivors and the game container mounted that whole directory at /downloads. That directory has siblings: work/, which is a full git archive of the source tree, and logs/. Fetching https://tds.doug.pt/downloads/work/<sha>/td-survivors/src/ returned 200 and served the source.

The fix is not "add rules to hide those", it is to make it structurally impossible: the build now publishes into a public/ subdirectory, and the compose file mounts only that. Logs stay in logs/, outside the mount, and no matter what the build leaves lying around in its working area, HTTP cannot reach it. Verified on the VPS: /downloads/ serves the index, /downloads/work/ and /downloads/logs/ return 404, and the 159 MB Windows zip downloads intact with a valid zip integrity check and TD Survivors.exe inside.

A mount is a permission. The right question is never "what do I need to hide here", it is "what is the smallest thing I can expose".

So there it is: the game runs offline, on two platforms, rebuilt on every push, five builds deep in history. It still needs a name.