Skip to content

Run the web game in the player's browser, with saves in IndexedDB - #148

Merged
dmccoystephenson merged 3 commits into
mainfrom
feature/pyodide-web-saves
Aug 2, 2026
Merged

Run the web game in the player's browser, with saves in IndexedDB#148
dmccoystephenson merged 3 commits into
mainfrom
feature/pyodide-web-saves

Conversation

@dmccoystephenson

Copy link
Copy Markdown
Member

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

PyodideUserInterface subclasses WebUserInterface and overrides only the two transport seams introduced here, _present and _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_PAGE into web/client.js and web/client.css, which the server-backed page inlines and the Pyodide page links. tests/web/test_clientParity.py asserts 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 onmessage handler. Shared memory needs no event loop, so that is how input reaches a blocked game. This is also why web/serve.py must send the COOP/COEP headers: without cross-origin isolation, SharedArrayBuffer does not exist and nothing the player clicks reaches the game. web/index.html says 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's syncSaves hook 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_DIR relocates the save directory (the Worker points it at its IndexedDB-backed /saves).
  • Deleting a slot in-game clears and rewrites the store, so a deletion sticks across a reload.

Deployment

web/serve.py serves the bundle and never runs the game. The Dockerfile serves that instead of examples/web_app.py, which means the image needs no pip 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 web
  • SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy python3 -m pytest --verbose -vv --cov=src --cov-report=term-missing --cov-report=xml:cov.xml716 passed, 97% coverage (src/ui/pyodideUserInterface.py 97%)
  • python3 -m black --check src tests web clean; autoflake run per format.sh
  • New tests: tests/ui/test_pyodideUserInterface.py (18), tests/web/ (13), tests/test_browserSaveSync.py (4), plus save-flush and FISHE_SAVE_DIR cases
    • Includes an end-to-end test that boots a real FishE through the Pyodide front-end against a fake js module whose syncSaves mirrors web/game-worker.js, and asserts the saved game lands in simulated browser storage
    • Includes a ring-buffer wraparound test and a test that quitting ends the page instead of surfacing SystemExit as a Worker error
  • Both browser front-ends touched and verified; console and pygame untouched
  • All four JS blocks (client.js, game-worker.js, and both inline transport scripts) pass node --check
  • web/serve.py smoke-tested live: / and /play serve the page with COOP/COEP set, /web/* serves the client, worker and 98 KB bundle, and /src/fishE.py is refused with a 404
  • Not verified: an actual browser loading Pyodide and playing a save through IndexedDB — there is no browser in the environment this was built in. The Python side of that path is covered by the end-to-end test above, and the JavaScript side mirrors Roam's proven implementation, but the first real load is worth watching.

No schema or save-format change: Player, Stats and TimeService are untouched, so schemas/*.json and the *JsonReaderWriter classes stay in sync.

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 dmccoystephenson left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/fishE.py
except (IOError, OSError) as e:
print(f"\n Warning: Failed to save game: {e}")
# Game continues even if save fails
return

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread web/index.html
function idbWrite(files) {
idbOpen().then((db) => {
let tx;
try { tx = db.transaction(IDB_STORE, "readwrite"); }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread web/index.html
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)) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread web/serve.py

def main():
host = os.environ.get("FISHE_WEB_HOST", "127.0.0.1")
port = int(os.environ.get("FISHE_WEB_PORT", "8080"))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Dockerfile
# front-end (examples/web_app.py) could not give them.
COPY src/ ./src/
COPY examples/ ./examples/
COPY web/ ./web/

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

dmccoystephenson and others added 2 commits August 1, 2026 22:22
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>
Under Pyodide the `js` module is importable, but it is not in
`sys.modules` until something actually imports it. Both browser-facing
modules checked `sys.modules` alone, so inside a real browser they
concluded they were *not* in a browser:

  - `pyodideUserInterface` raised "The Pyodide front-end can only run
    inside a browser", so the page never started at all.
  - `browserSaveSync` silently returned False, which would have made
    every save a no-op that never reached IndexedDB.

Both now go through a single `getJsModule()` that attempts the real
import and falls back to None only when it genuinely fails.
`sys.modules` is still consulted first so tests can inject a stand-in.

Regression test writes a temporary `js.py` on sys.path with `js`
removed from sys.modules — the exact shape of the bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dmccoystephenson
dmccoystephenson merged commit 753c23f into main Aug 2, 2026
1 check passed
@dmccoystephenson
dmccoystephenson deleted the feature/pyodide-web-saves branch August 2, 2026 15:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant