A text-adventure kit. One user-interface contract with three front-ends behind it — a terminal, a browser that polls a server, and a browser tab that runs the whole game itself under Pyodide — plus the pieces a menu-driven text game needs around it: numbered save slots with JSON Schema validation, a progressive-disclosure unlock engine, NPC dialogue with conditional lines, and clock formatting for a day-based header.
examples/minimal_game.py played in the console front-end: an unavailable option, a line that unlocks, and the gate opening.
Extracted from FishE. Tidewater is the first game built on it; FishE is being moved onto it.
pip install "tak @ git+https://github.com/Stephenson-Software/tak@v0.5.0"Python 3.8+. The only dependency is jsonschema.
examples/minimal_game.py is the whole of it in 120 lines: three scenes, one villager with a line that unlocks, a header, no saves. python3 examples/minimal_game.py plays it in the terminal, --web in a browser. Copy it and replace the words.
A game is a synchronous loop that talks to the player through BaseUserInterface's primitives and never imports a concrete front-end:
from tak import Prompt
from tak.ui import UIType, createUserInterface
prompt = Prompt("You wake on the docks.")
header = lambda: {"title": "Tidewater — Loop 1", "chips": ["Loop 1", "8:00 AM"]}
ui = createUserInterface(UIType.CONSOLE, prompt, header, title="Tidewater", envPrefix="TIDEWATER")
choice = ui.showOptions("The Docks", ["Fish", "Go to the tavern"], unavailableOptions={2: "it's not open yet"})
ui.showDialogue("The tide is out.")
name = ui.promptForText("What is your name?")
ui.cleanup()| Primitive | What it does |
|---|---|
showOptions(descriptor, options, unavailableOptions=None) |
Numbered menu; returns the chosen number as a string. Unavailable rows are listed but greyed with their reason. |
showDialogue(text) |
A block of text and a Continue. |
promptForText(text) / promptForNumber(text) |
Free-text / numeric input. |
showBusy(message, seconds) |
A pause with a message, no input. |
timedKeyPress(message) |
Seconds until the player reacts. |
showInteractiveDialogue(npc) |
A conversation menu built from an NPC's options. |
setPicture(key, alt) |
Optional and sticky: shows pictures/<key>.png above every following screen in the browser front-ends until the next call (None clears it). alt is required. The console shows nothing. |
The header callable is the game's status line, read fresh before every menu. Its title is the browser tab's title (the console ignores it); the page heading is the title given to createUserInterface. The kit knows nothing about days or money — chips are whatever the game says, and an optional class on a chip ({"text": "Energy: 4/10", "class": "low"}) lets the browser style it. The console prints the chips on one line, separated by |, and starts a new line between two chips only when the terminal is too narrow for the next one.
UIType |
Where the game runs | Saves live |
|---|---|---|
CONSOLE |
the terminal | on disk |
WEB |
a Python process; the browser is a terminal for it (<PREFIX>_WEB_HOST/_PORT, default 127.0.0.1:8000) |
on the server's disk, shared by every visitor |
PYODIDE |
inside the player's browser tab | in that browser's IndexedDB |
Desktop (--web) |
Phone width |
|---|---|
![]() |
![]() |
The same example game in the WEB front-end (python3 examples/minimal_game.py --web).
from tak.saves import SaveFileManager, validateAgainstSchema, syncBrowserSaves
saves = SaveFileManager("data", primaryFile="save.json",
readMetadata=lambda slotPath, data: {"loop": data["loop"]})
slot = saves.get_next_available_slot()
saves.select_save_slot(slot)
validateAgainstSchema(state, "schemas/save.json")
with open(saves.get_save_path("save.json"), "w") as f: json.dump(state, f)
syncBrowserSaves() # no-op outside the browser; required after every write under PyodidechooseSlot(ui, saves, "Tidewater", describe) runs the opening menu — load, new, delete, quit — and returns ("load", n), ("new", n) or None. A slot whose primary file will not parse stays listed as damaged and stays claimed, so a new game is never pointed at an occupied directory. get_next_available_slot() decides "free" from the disk, not from the list: any slot_N directory holding a file is taken even if the menu could not show it, and chooseSlot refuses to start a new run in a slot that holds files.
In the browser, each sync writes the save directory's files to IndexedDB in one all-or-nothing transaction and never clears the store. A stored file is deleted only when this tab restored or saved it and the game then removed it (deleting a slot, a migration moving a file); a stored save the tab never had, such as one written by another tab, is left alone.
from tak import Progression, NPC
progression = Progression([
{"id": "tavern", "name": "the tavern", "announcement": "Old Tom's door is open to you.",
"condition": lambda state: "tom_knows_you" in state.facts},
])
unlock = progression.getNextUnlock(state, state.unlocked) # one per call; appended to the list you own
tom = NPC("Old Tom", "Keeps the tavern.", [
{"question": "About the storm...", "response": "How could you know that?",
"condition": lambda: "storm" in state.facts},
])The kit carries the browser side: client.js/client.css (the renderer both web front-ends share), boot.js (SharedArrayBuffer input ring, IndexedDB save mirror, Worker wiring) and game-worker.js (loads Pyodide, restores saves, unpacks the bundle, runs the game). A game adds three small files:
web/index.html
<link rel="stylesheet" href="/tak/client.css">
<div id="status"></div><div id="app"></div>
<script src="/tak/client.js"></script>
<script src="/tak/boot.js"></script>
<script>TakBoot.start({ idbName: "tidewater-saves", saveDirEnv: "TIDEWATER_SAVE_DIR" });</script>web/pyodide_main.py — builds the game with UIType.PYODIDE and plays it.
web/build_zip.py and web/serve.py
from tak.web.bundle import build; build(ROOT, extraFiles=("version.txt", "web/pyodide_main.py"))
from tak.web.serve import main; main(ROOT, title="Tidewater", envPrefix="TIDEWATER")build puts the game's src/ and schemas/ and the tak package itself into web/game.zip (an extraFiles entry that does not exist is skipped, with a warning on stderr naming it); serve sends the Cross-Origin-Opener-Policy/Cross-Origin-Embedder-Policy headers SharedArrayBuffer needs and serves the kit's assets at /tak/. Any proxy in front of it must preserve those headers.
serve answers three kinds of path and returns 404 for everything else:
/,/play,/play/and/index.htmlserve the game'sweb/index.html./tak/<name>serves one of the kit's assets./web/...serves files from the game'sweb/directory, includinggame.zip.
Files the page needs must therefore live under web/. serve listens on <PREFIX>_WEB_HOST/<PREFIX>_WEB_PORT, default 127.0.0.1:8080, where <PREFIX> is the envPrefix passed to main. Set the host to 0.0.0.0 to reach it from a container's port mapping. The default port is deliberately not the WEB front-end's 8000, so both can run at once.
Every TakBoot.start key is optional. The defaults fit the layout above:
| Key | Default | What it sets |
|---|---|---|
idbName |
"tak-saves" |
The IndexedDB database holding this game's saves. Give each game its own name, and never change it once players have saves under it. |
saveDirEnv |
"TAK_SAVE_DIR" |
The environment variable set to the save directory before entry runs. Use the variable the game's own config reads. |
saveDir |
"/saves" |
The save directory in Pyodide's filesystem. |
bundleUrl |
"/web/game.zip" |
Where the Worker downloads the bundle tak.web.bundle.build wrote. |
entry |
"web/pyodide_main.py" |
The file inside the bundle that is run. |
packages |
[] |
Pyodide packages to load in addition to jsonschema, which is always loaded. |
pyodideUrl |
jsDelivr's pyodide.js for the version game-worker.js pins |
Where the Pyodide runtime is loaded from. |
messages |
English boot messages | Overrides for the boot progress text, by key: runtime, restore, packages, download, start. |
statusElementId |
"status" |
The element boot progress and errors are shown in. |
logPrefix |
"[tak]" |
Prefix on the lines the game's output and the kit write to the browser console. |
workerUrl |
"/tak/game-worker.js" |
Where the Worker script is served. |
savesUrl, cloudUrl, arcadeUrl, hallUrl |
"/tak/saves.js", "/tak/cloud.js", "/tak/arcade.js", "/tak/hall.js" |
Where the save-transfer, cloud-save, arcade and back-to-the-hall scripts are served (see the sections below). |
cloudSaves |
false |
Set true to turn on cloud saves on arcade. |
picturesDir |
"pictures" |
Where, inside the bundle, the PNGs named by setPicture are. |
A game may show one picture per place with ui.setPicture("strip", "The airstrip under snow.") when the player enters it. Only the key travels with each screen. In the PYODIDE build the Worker reads <picturesDir>/<key>.png from the unpacked bundle and sends its bytes to the page the first time the key appears, and the page keeps a blob: URL for the rest of the session. The WEB front-end serves the same file at GET /picture/<key> from its picturesDir (default pictures, relative to where the game is run). Keys are lower-case letters, digits, - and _, so a request cannot name a file outside that directory. To ship the pictures, add the directory to the bundle: build(ROOT, sourceDirectories=("src", "schemas", "pictures"), ...).
A small game that only uses input() and print() does not need rewriting
against the kit to be played in a browser. tak.web.console runs it unmodified
under Pyodide:
- Its prompts and output go to a terminal on the page. The terminal uses a real text field, so a phone's keyboard, paste and autocorrect-off all work.
input()blocks on the same SharedArrayBuffer ring as the kit's own front-end.os.system("cls")or"clear"and ANSI clear-screen codes clear the terminal. Other escape codes are dropped.- Any file the program creates or changes under its directory (a save file, a
saves/folder) is kept in the browser's IndexedDB and restored on the next visit.
# web/build_zip.py
from tak.web.bundle import build
from tak.web.console import page
open("web/index.html", "w").write(
page(title="Guess My Word", entry="src/guessMyWord.py", idbName="guess-my-word-files"))
build(ROOT, sourceDirectories=("src",), extraFiles=("version.txt",))entry is run as __main__ with its own directory as the working directory. idbName must be
unique per game and must never change once players have saves under it. The page loads
/tak/console.css, /tak/console.js and /tak/console-worker.js, which tak.web.serve and
arcade both serve. Like every tak browser build,
it must be served cross-origin isolated.
Both browser runtimes put a small Saves button under the game. It opens a panel with
Download my saves and Load saves from a file. A browser's saves live only in that
browser, so this is how a player keeps a copy, moves to another device or browser, or follows a
game to a new address. Neither page has to change: boot.js and console.js load
/tak/saves.js themselves. Set savesUrl in the start config if it is served somewhere else.
The file is one JSON document named <game>-saves-YYYY-MM-DD.json:
{"format": "tak-saves", "version": 1, "game": "tidewater-saves",
"exported": "2026-10-03T12:00:00.000Z",
"files": {"/saves/slot_1/save.json": "{...}", "/game/src/saves/a.bin": {"base64": "AP8K"}}}game is the page's idbName. Each file is stored as it was kept: text stays a string (tak's own
front-end), and bytes become {"base64": …} (the console runtime). Download only reads the
browser's storage, so the game keeps running.
Loading a file never loses a save:
- Validation before anything is written. The whole file is checked before storage is touched.
A file is refused, with nothing changed, if it is not a tak saves file, if its version is newer,
or if it is another game's file ("This file holds saves for another game (overwinter), not this
one (tidewater)"). It is also refused if it holds a path outside the runtime's save root
(
/saves/for tak's front-end,/game/for the console runtime), a..or empty path part, or damaged content, or if it is over 20 MB. - Confirmation. The panel lists the files that will be added, replaced by the copy in the file, and kept as they are. Nothing is written until the player confirms.
- Stop, then write. On confirm, the game is stopped first. The runtime terminates its Worker, and from then on it ignores any save write of its own. Each runtime writes its own copy of every save on every sync, so a running game would otherwise overwrite an imported slot on its next save. Other open tabs of the same game are told to stop too.
- Backup first. The saves as they are at that moment are copied to a second IndexedDB
database,
<idbName>.tak-backups, and read back. The last five are kept, and the panel offers each one as a download. If the backup fails, nothing else happens. Loading a backup file puts back every save the import replaced. - Merge, never delete. Each imported file replaces the file at the same path. Every other save is kept. The result is read back and checked, then the page reloads and the game restores from storage as on any visit.
A tab still running a tak release from before this feature cannot be told to stop, so close other tabs of the game before loading saves.
A game on arcade (https://<slug>.play.danielstephenson.dev) can let a signed-in player back
its saves up to their arcade-social account and get them on their other devices
(Stephenson-Software RFC 0016). It is opt-in twice: the game sets cloudSaves: true in
TakBoot.start, and arcade-social must list the game in the gateway's config/play/saves.yaml.
Then the Saves panel offers Turn on cloud backup to a signed-in player. Nothing is sent
until the player turns it on, anywhere else (localhost, an alias host, a desktop build) nothing is
loaded at all, and the console runtime does not support it.
TakBoot.start({ idbName: "night-ferry-saves", /* ... */ cloudSaves: true });boot.js then loads /tak/cloud.js (cloudUrl to override). The game still saves to IndexedDB
first, exactly as before, and the cloud is never in the save path:
- Backup (Stage 1). After each committed save (at most one upload per 30 s) the store is
uploaded as a save file, based on the version this browser last uploaded or pulled. The server
refuses anything not based on its newest version (409); the page then merges per slot, and a
slot changed on two devices is kept twice, the second copy going into the next free
slot_N. Nothing is ever newest-wins. Saves → Cloud versions lists every version, each with Load this version (the file import above, with its preview, backup and reload) and Download. - Sync (Stage 2, when
saves.yamlsetspull: true). In a browser that turned it on, a sync runs before the game starts (at most about 3 s; a slow network delays nothing longer). If the account has newer saves, they come down as an import: validated, the store backed up into<idbName>.tak-backupsand read back first, written put-only (no local file is ever deleted), read back, then the page reloads once. A pull never begins after the game has started. - Errors are never "empty". A failure of any kind (offline, signed out, a 5xx, the kill switch) is "unknown": nothing is uploaded or written because of it, and the panel says "Not backed up".
- Lost slots are carried, not dropped. A slot missing from this browser that the game did not delete is kept in the cloud from the last version, restored here at the next load, and if a new game is started in that slot meanwhile, both are kept.
- Deletions stay local. A slot deleted in the game is deleted in the cloud's newest version (history keeps it), but never on another device: it stays there until deleted there too.
- A session whose restore failed uploads nothing.
cloud.js's engine is tested under Node by tests/web/test_cloud_saves.py, including a
randomized three-device test (tests/web/cloud_sim.js) against an in-memory server with
arcade-social's rules; arcade-social keeps a Python copy of the same algorithm in its own tests.
tak.arcade reports a player's scores and achievement unlocks to
arcade-social (https://api.play.danielstephenson.dev),
which shows them as leaderboards and "N% of players" on the game's page at
danielstephenson.dev/play (Stephenson-Software RFC 0014):
from tak import arcade
arcade.submitScore("most-money", 12450) # returns None; never raises
arcade.submitScore("fastest-crossing", 41.7, run="seed-12") # run: optional tag, 1-64 of A-Za-z0-9._:-
arcade.unlock("first-catch") # idempotent; never raises- Declare first. Boards and achievements are declared in the gateway's
config/play/boards.yaml(ids^[a-z][a-z0-9-]{1,30}$, never renamed once used; each board hasmin/max). The service refuses anything undeclared or out of bounds. - Call unconditionally. Outside the browser (console, the HTTP front-end, tests) both calls do
nothing. In the browser the Worker posts each report to the page (
{type: "arcade"}), andboot.jsloads/tak/arcade.json the first one, which sends it from the main thread with the player's sign-in cookie. Nothing is ever waited for and nothing is thrown into the game. - Silently dropped when the page is not
https://<slug>.play.danielstephenson.dev(an alias such asfishe.danielstephenson.dev,localhost, a desktop build), when the player is not signed in (checked once a minute at/v1/session; a score earned signed out is never uploaded later), when the service refuses it (undeclared, out of bounds, no display name yet,maxPerHour), or after one retry 30 s later when the service is down or unreachable. Malformed calls (a bad id, a non-finite value) are ignored. - Send every score. The client does not know the player's stored best, so it does not filter; the service keeps each player's best per board.
- Scores are forgeable. They come from the player's browser, and every leaderboard is labelled "not verified". Do not attach anything of value to them.
- The console runtime (unmodified console programs) has no
takArcadebridge, so the calls are no-ops there. SetarcadeUrlinTakBoot.startifarcade.jsis served somewhere other than/tak/arcade.js.
On arcade, both runtimes (TakBoot.start and TakConsole.start) put a small ← Arcade link at
the top of the page, above the title. It goes to the game's own cabinet in the walkable hall,
https://play.danielstephenson.dev/hall/?at=<slug>, where <slug> is the first label of
<slug>.play.danielstephenson.dev. The old alias hosts tidewater., overwinter. and
fishe.danielstephenson.dev link to their games' cabinets too.
- Nowhere else. Not on
localhost,tak.web.serve, or any other host, and not when the page is framed (the hall's overlay anddanielstephenson.dev/play/<id>/playhave their own way back).hall.jsis not even fetched offdanielstephenson.devor inside a frame. - A plain link. A full page load, in the page's flow (never over the game's text or controls). It reads and writes no storage and sends no request; the game's saves stay in the browser as always and load as before when the player comes back. Enter on the focused link follows it rather than also continuing a dialogue.
hallUrlin the start config overrides wherehall.jsis served from. The host logic is tested under Node bytests/web/test_hall_js.py.
pip install -e '.[dev]'
./test.sh
./format.shdocs/images/tak-console.gif was recorded on 2026-10-18 from a real run of python3 examples/minimal_game.py (Python 3.12, tak installed with pip install -e .), with the menu choices typed by a script. The recorded terminal output was rendered to an image with pyte and Pillow; nothing in it was edited, only the window frame was added. Pauses longer than 2.2 seconds were shortened in the GIF. docs/images/tak-web.png and docs/images/tak-web-phone.png are headless-Chromium screenshots of python3 examples/minimal_game.py --web at 1100 px and 390 px wide, cropped to the game. docs/images/tak-hall-link.png and docs/images/tak-hall-link-phone.png (2026-10-29) are headless-Chromium screenshots of the live Tidewater page at 1280 px and in Playwright's iPhone 13 profile, with this branch's boot.js and hall.js served in place of the deployed ones, cropped to the top of the page.
This project is licensed under the Stephenson Software Non-Commercial License (Stephenson-NC).
© 2026 Daniel McCoy Stephenson. All rights reserved.
You may use, modify, and share this software for non-commercial purposes only.
Commercial use is prohibited without explicit written permission from the copyright holder.
Full license text: Stephenson-NC License (also in LICENSE)
SPDX Identifier: Stephenson-NC




