Run the web game in the player's browser, with saves in IndexedDB - #148
Run the web game in the player's browser, with saves in IndexedDB#148dmccoystephenson wants to merge 2 commits into
Conversation
FishE's web front-end ran the game on a server and wrote save slots to the server's disk, so everyone who opened the page shared one game and one set of saves. This adds a second browser front-end that runs the whole Python game in the player's own tab under Pyodide, keeping that tab's save slots in the browser's IndexedDB — the approach Roam already uses for its web build. - PyodideUserInterface subclasses WebUserInterface and overrides only the two transport seams (_present/_awaitInput), so every screen is still defined once and both browser front-ends stay in step. Screens are posted to the main thread; input arrives over a SharedArrayBuffer ring, because the synchronous game loop blocks the Worker and a blocked Worker can never run onmessage. - The renderer and styles both browser front-ends use are now one shared web/client.js + web/client.css, inlined by the server-backed page and linked by the Pyodide one, instead of a second copy of the renderer. - browserSaveSync flushes the save directory to IndexedDB after every write, delete and migration; FISHE_SAVE_DIR relocates that directory. - web/serve.py serves the bundle with the COOP/COEP headers SharedArrayBuffer requires, and never runs the game. The Dockerfile serves that instead of examples/web_app.py, so the image needs no pip install and no save volume. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dmccoystephenson
left a comment
There was a problem hiding this comment.
Self-review. Read the full diff; no correctness defects found, and CI is green (716 tests, 97% coverage). Four notes below record decisions a future reader is likely to second-guess, plus the one thing this change genuinely cannot verify from the environment it was built in.
The one open risk is stated in the PR description and repeated here: no browser was available to load the page, so Pyodide booting, loadPackage(['jsonschema']), and a real IndexedDB round-trip are unverified end-to-end. Everything on the Python side of that path is covered by a test that runs a real FishE through PyodideUserInterface against a fake js module mirroring game-worker.js, and the JavaScript mirrors Roam's production implementation — but the first real page load should be watched rather than assumed.
One dependency of the design worth recording since it is invisible from the code: COEP require-corp blocks cross-origin subresources that do not opt in, and the Worker loads Pyodide from jsdelivr via importScripts. That was checked against the live CDN, which sends cross-origin-resource-policy: cross-origin — so the boot is not blocked by the very headers SharedArrayBuffer requires. If Pyodide is ever moved to another CDN, that property has to be rechecked.
| except (IOError, OSError) as e: | ||
| print(f"\n Warning: Failed to save game: {e}") | ||
| # Game continues even if save fails | ||
| return |
There was a problem hiding this comment.
This early return is load-bearing rather than tidying. The three save files are written in sequence, so a failure partway through leaves the save directory in a mixed state; returning here skips the flush below, which leaves the previous, consistent set in browser storage rather than overwriting it with a half-written one. Worth keeping this ordering in mind if a fourth save file is ever added.
| function idbWrite(files) { | ||
| idbOpen().then((db) => { | ||
| let tx; | ||
| try { tx = db.transaction(IDB_STORE, "readwrite"); } |
There was a problem hiding this comment.
The clear() and the put()s share one transaction deliberately: the file map is the whole save directory, so a delete only sticks if the store is emptied first — and doing both in one transaction means a tab closed mid-write aborts the transaction and keeps the previous saves, instead of leaving the player with a cleared store.
| const bytes = new TextEncoder().encode(text + "\n"); | ||
| const writeIndex = Atomics.load(sabMeta, 0); | ||
| const readIndex = Atomics.load(sabMeta, 1); | ||
| if (bytes.length > RING_SIZE - (writeIndex - readIndex)) { |
There was a problem hiding this comment.
This fullness check relies on the ring indices being monotonic, which makes it wrong after writeIndex overflows Int32Array's range — about 2 GB of cumulative player input in a single page load. That is unreachable for a turn-based game where each message is a few dozen bytes and the game drains one per screen, so it is left as-is rather than complicated with wraparound arithmetic. Flagging it in case this ring is ever reused for something higher-volume, like mouse movement.
|
|
||
| def main(): | ||
| host = os.environ.get("FISHE_WEB_HOST", "127.0.0.1") | ||
| port = int(os.environ.get("FISHE_WEB_PORT", "8080")) |
There was a problem hiding this comment.
FISHE_WEB_HOST/FISHE_WEB_PORT are reused from the server-backed front-end on purpose — they are the same question, "where does FishE listen" — but the default differs (8080 here, 8000 there). Anything that pinned FISHE_WEB_PORT=8000 for the old service will keep binding 8000 after switching to this entry point, which is silent rather than broken. The deployment that switches over has to move its port mapping and healthcheck together.
| # front-end (examples/web_app.py) could not give them. | ||
| COPY src/ ./src/ | ||
| COPY examples/ ./examples/ | ||
| COPY web/ ./web/ |
There was a problem hiding this comment.
Note for anyone reading this later: dropping pip install and examples/ means this image can no longer run the server-backed front-end at all — overriding CMD back to examples/web_app.py fails on the missing jsonschema. That is the intent, not an oversight. The absence of a save volume is the point of the change: there is no server-side save state left to persist.
PyodideUserInterface subclasses WebUserInterface, which read web/client.css and web/client.js at import time to build its own page. Those files reach the browser over HTTP and are not on the filesystem the Worker sees, so importing the front-end raised FileNotFoundError on /game/web/client.css and the game never started. The page is now built on first use and cached, so only the front-end that actually serves it reads those files; HTML_PAGE/CLIENT_CSS/CLIENT_JS stay readable as module attributes via PEP 562. build_zip.py also ships the two client files, so the read cannot fail in the browser even if something makes it happen again. Regression test drives the Pyodide front-end with WEB_ASSET_DIRECTORY pointed at a directory that does not exist, which is the situation in the Worker. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
FishE's web front-end ran the game on the server and wrote save slots to the server's disk, so every visitor to a deployment shared one game loop and one set of save files. This adds a second browser front-end that runs the whole Python game in the player's own tab under Pyodide, with that tab's save slots kept in the browser's IndexedDB — the same approach Roam uses for its web build.
The server-backed front-end (
UIType.WEB,examples/web_app.py) is unchanged and still there; the deployed image now serves the browser-native one.Front-end parity
PyodideUserInterfacesubclassesWebUserInterfaceand overrides only the two transport seams introduced here,_presentand_awaitInput. Every screen — options, dialogue, prompt, busy, timed, ended — is still defined exactly once, so the two browser front-ends cannot drift.The browser half is shared the same way: the renderer and styles moved out of
HTML_PAGEintoweb/client.jsandweb/client.css, which the server-backed page inlines and the Pyodide page links.tests/web/test_clientParity.pyasserts the sharing itself.Why a SharedArrayBuffer
FishE's game loop is synchronous, so it blocks the Worker whenever it waits for the player — and a blocked Worker can never run an
onmessagehandler. Shared memory needs no event loop, so that is how input reaches a blocked game. This is also whyweb/serve.pymust send the COOP/COEP headers: without cross-origin isolation,SharedArrayBufferdoes not exist and nothing the player clicks reaches the game.web/index.htmlsays exactly that on screen if it happens.For the same reason, IndexedDB writes happen on the main thread rather than in the Worker: the Worker posts the save file map over, and the page stores it.
Saves
browserSaveSync.syncBrowserSaves()flushes the save directory through the Worker'ssyncSaveshook after every save write, slot deletion and legacy migration. It is a no-op outside Pyodide, where the files on disk are the real saves.FISHE_SAVE_DIRrelocates the save directory (the Worker points it at its IndexedDB-backed/saves).Deployment
web/serve.pyserves the bundle and never runs the game. The Dockerfile serves that instead ofexamples/web_app.py, which means the image needs nopip install(the game's one dependency,jsonschema, is loaded browser-side) and no save volume at all.Test plan
python3 -m compileall -q src tests webSDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy python3 -m pytest --verbose -vv --cov=src --cov-report=term-missing --cov-report=xml:cov.xml— 716 passed, 97% coverage (src/ui/pyodideUserInterface.py97%)python3 -m black --check src tests webclean;autoflakerun performat.shtests/ui/test_pyodideUserInterface.py(18),tests/web/(13),tests/test_browserSaveSync.py(4), plus save-flush andFISHE_SAVE_DIRcasesFishEthrough the Pyodide front-end against a fakejsmodule whosesyncSavesmirrorsweb/game-worker.js, and asserts the saved game lands in simulated browser storageSystemExitas a Worker errorclient.js,game-worker.js, and both inline transport scripts) passnode --checkweb/serve.pysmoke-tested live:/and/playserve the page with COOP/COEP set,/web/*serves the client, worker and 98 KB bundle, and/src/fishE.pyis refused with a 404No schema or save-format change:
Player,StatsandTimeServiceare untouched, soschemas/*.jsonand the*JsonReaderWriterclasses stay in sync.