- Rust 96.3%
- CSS 3.6%
- HTML 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .cargo | ||
| .github/workflows | ||
| backend | ||
| cli | ||
| core | ||
| crypto | ||
| frontend | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CLAUDE.md | ||
| Colostle_compressed.pdf | ||
| quick_start.md | ||
| README.md | ||
| rust-toolchain.toml | ||
| rustfmt.toml | ||
| tables_en.toml | ||
| tables_fr.toml | ||
| TODO.md | ||
| Trunk.toml | ||
Colostle
A Rust implementation of Colostle, a solo tabletop RPG set inside a vast castle called the Colostle, inhabited by giant chess-piece-like entities called Rooks. You play as The Helmed — a tinkerer driven to understand Rook technology, armed with a Rookstone-powered crossbow.
Game outcomes are driven by draws from a standard 52-card deck. Card suits and values determine exploration encounters, combat results, loot, and narrative prompts.
Just want to play? See quick_start.md.
Workspace layout
colostle/
├── core/ # Game logic (I/O behind a trait), tables, campaign save/load
├── crypto/ # Client-side encryption: key derivation, sealing, recovery
├── cli/ # Terminal frontend (blocking stdin/stdout, local saves)
├── frontend/ # Leptos web app (client-side WASM, built with Trunk), account-gated
└── backend/ # Axum HTTP server — serves the web build + stores sealed campaigns
Playing the game
Terminal (CLI)
cargo run -p colostle-cli [-- --tables <path>] [--campaign <id>]
The CLI opens with a campaign menu — pick a number to resume, n for a new one,
d <n> to delete. --campaign <id> skips the menu and resumes that campaign
directly; --tables <path> loads custom encounter tables (see
Custom tables).
State is saved after each exploration phase to
{data_dir}/colostle/campaigns/{id}/ (on macOS, {data_dir} is
~/Library/Application Support). Set COLOSTLE_DATA_DIR to relocate it.
Unlike the web app, the CLI has no account and no login: it keeps campaigns as plain TOML on your own machine, protected by the same thing that protects the rest of your home directory. It does not talk to the server.
Web app
The frontend is a client-side Leptos app compiled to WASM by Trunk. The Axum backend serves the result and the API from one origin.
# 0. One-time: install the target and Trunk
rustup target add wasm32-unknown-unknown
cargo install trunk --locked
# 1. Build (from the repository root; writes frontend/dist/)
trunk build --release
# 2. Serve
cargo run -p colostle-backend # listens on http://localhost:3000
Use --release even while developing: signing in runs Argon2id in the
browser, which takes a second when optimised and much longer when not. A plain
trunk build works too, but expect slow sign-ins.
To work on the UI with automatic rebuilds, keep the backend running and use
trunk serve instead. It serves the app on http://localhost:8080, rebuilds on
every change, and forwards /auth and /campaigns to the backend on port 3000
(see Trunk.toml).
In production, run the backend in release mode with real secrets:
trunk build --release
JWT_SECRET="$(openssl rand -hex 32)" \
COLOSTLE_CORS_ORIGIN='https://your.domain' \
cargo run --release -p colostle-backend
Open http://localhost:3000 in your browser. The whole app sits behind an account: you will be asked to sign in or create one before anything else appears. Creating an account issues a recovery phrase — write it down. It is the only way back in if you forget your password, and the server cannot help, because it has never seen your key.
Signing in, managing campaigns and creating a character are ordinary forms.
The campaigns screen lets you create, rename, delete and import campaigns,
change your password and delete your account. Import takes the
player.toml, deck_state.toml and journal.toml of a campaign played in the
CLI; the files are read and encrypted in the browser and uploaded already sealed.
Once a campaign is open the game itself is a chat log: you type, it narrates. Around it:
- a "This turn" tray above the input shows each card drawn this phase, with its encounter and any follow-up it triggered; it clears once the journal entry is written;
- a slide-in drawer holds the Journal (past entries, with the cards drawn for each), the Inventory (items can be removed from here) and the Character sheet;
- the header has a Campaigns button to step back out.
Development
cargo build # compile everything (the frontend as a host stub)
trunk build --release # build the web app itself
cargo test # run all tests
cargo test <test_name> # run a single test by name
cargo clippy --all-targets -- -D warnings # lint exactly as CI does
cargo fmt # format
cargo doc --open # open rustdoc in browser
Code quality standards
CI (.github/workflows/ci.yml) runs check, test, clippy and fmt --check
on every PR to main, and fails if any of these are broken:
#![forbid(unsafe_code)]in every crate — nounsafeblocks anywhere.- Clippy
pedantic+nurserygroups enabled at the workspace level (Cargo.toml [workspace.lints]), plusunwrap_in_resultandpanicwarnings;dead_codeandunused_importsare denied. - Zero warnings: CI runs
cargo clippy --all-targets -- -D warnings. - Rustfmt: run
cargo fmtbefore pushing.rustfmt.tomlpins the style. - Pinned toolchain:
rust-toolchain.tomlselects the stable channel so local and CI builds agree. - Property tests: deck invariants live in
core/tests/deck_properties.rsusingproptest. Add new properties there when touching card mechanics.
Accounts and encryption
The web app is entirely behind an account, and the server is zero-knowledge: it stores no username, no email, no campaign names and no readable game state. An operator with a full copy of the database learns how many accounts exist and how often they play — nothing more.
How it works
Everything is derived and encrypted in the browser, in crypto/:
username --blake3--> handle the only identifier the server holds
password --Argon2id(salt = blake3(username))--> root_key
|
+-------------+-------------+
derive("auth") derive("enc")
| |
auth_secret --> server master_key --> never leaves the browser
|
+--seals--> account_key <--seals-- recovery_key
|
+--seals--> campaign_key
|
+--seals--> name + state
The server receives auth_secret and Argon2-hashes it again before storage, so
even a database dump yields nothing replayable as a login. It receives the
account key only in its wrapped form and has no key that opens it.
Two consequences follow directly from the layout, and both are the point:
- Changing a password re-seals 32 bytes. The master key wraps only the account key, so campaign data is never re-encrypted however much of it there is.
- A lost password is survivable; losing the password and the recovery phrase is not. There are exactly two wrappers around the account key and the server holds neither. This is a deliberate limit, not a gap.
The KDF salt comes from the username rather than the server, so a client finishes deriving before it makes any request — which is why signing in never reveals whether an account exists.
What this does not defend against: a server that ships malicious JavaScript
to the browser. No browser-delivered end-to-end encryption can, since the code
doing the encrypting arrives from the same place the ciphertext is stored. The
Content-Security-Policy the server sends is what narrows that surface, and it
is load-bearing rather than decorative.
Backend
The backend is an Axum server that serves the compiled web app and stores encrypted campaigns for it. Its environment variables, database schema, API and production checklist are documented in backend/README.md.
In short: cargo run -p colostle-backend works out of the box in debug; a
release build additionally requires JWT_SECRET and COLOSTLE_CORS_ORIGIN.
Content Security Policy
The server sends a CSP, and it is doing real work: the encryption keys live in the browser, so any script running on this origin can read them.
The same policy is sent in every build. It never allows 'unsafe-eval', which
would let injected script build functions from strings — the shortest path to
the account key. Nothing in the app needs it: the frontend's few DOM operations
go through web-sys (frontend/src/dom.rs).
Two allowances remain:
'wasm-unsafe-eval'is how browsers gate compiling WebAssembly. The whole app is WASM, so it is required, and it is not generaleval.'unsafe-inline'for scripts is required by the inline module script Trunk writes intoindex.htmlto load the WASM. Its hash changes with every build. This is the weakest part of the policy.
trunk serve serves the page itself, so the backend's CSP does not apply to
it. Test anything CSP-sensitive against the backend.
colostle.db is gitignored. Do not commit it — it once was, and the Argon2
hashes in that commit are still in the repository's history.
Architecture
I/O model
The game engine in core/ is fully decoupled from I/O. All output goes through the IoProvider trait; all input comes back through it. This allows the same logic to run in:
- A blocking terminal (the CLI's
TerminalIo) - An async task communicating via channels (
GameSession, used by the Leptos frontend)
┌─────────────────────────────────────────────────┐
│ game loop (core) │
│ io.print_text(...) ──► channel ──► UI │
│ io.read_line() ◄── channel ◄── UI │
└─────────────────────────────────────────────────┘
GameSession async execution model
| Platform | Execution model | Blocking call |
|---|---|---|
| Native | std::thread with pollster::block_on |
Blocking recv() inside block_on |
| WASM | wasm_bindgen_futures::spawn_local |
Cooperative recv_async().await on JS event loop |
The game loop is fully async — it yields at every I/O read point using await. On native, this runs inside std::thread with pollster::block_on; on WASM, it runs as a JS microtask via spawn_local. The UI side always uses session.next_event().await (non-blocking).
Storage
| Target | Implementation | Location |
|---|---|---|
| CLI (native) | Filesystem | {data_dir}/colostle/campaigns/{id}/{meta,player,deck_state,journal}.toml |
| Web | Encrypted, on the server | Sealed in the browser; see Accounts and encryption |
Native saves are written to a temporary file and renamed, so an interrupted
save leaves the previous campaign intact. Set COLOSTLE_DATA_DIR to relocate
the tree. A pre-multi-campaign save sitting flat in {data_dir}/colostle/ is
migrated into campaigns/default/ on first run.
The web build has no local storage path at all, and colostle_core::save's
storage functions do not exist on wasm32. Plaintext campaign TOML sitting in
LocalStorage would hand the whole campaign to any injected script, so
reintroducing a local write is a compile error rather than a review question.
sessionStorage holds the token and the unwrapped account key so a page
refresh does not cost another Argon2id derivation; the browser discards both
when the tab closes. localStorage holds one thing — the last username typed,
to pre-fill the prompt. It is never sent to the server, which sees only its hash.
Crate dependency graph
colostle-cli ──► colostle-core
colostle-frontend ──► colostle-core, colostle-crypto
colostle-backend (standalone — no core dependency)
colostle-crypto (no dependencies on the other crates)
Game mechanics
Exploration phase
- Draw cards equal to your exploration score (minimum 1)
- Look up each card in the encounter tables
- Red cards vs black cards determine outcome direction
- Follow-up events may trigger: hunts, gifts, Rookstone finds, ocean encounters, loot
- Write a journal entry
- State is saved automatically
Environments
- Roomlands — castle interior; rooms, Rooks, NPCs
- Ocean — maritime; treasure, pirates, weather
Scores
- Exploration score — cards drawn per phase; increases on Rook defeat or Rookstone find
- Combat score — used during combat; increases through victories
Combat
- Draw cards equal to your combat score
- Assign cards to your equipped weapon slots (exploration or combat damage)
- The Rook draws cards; highest total wins the round
- Wounds reduce the loser's relevant score
- Combat ends when one side reaches score 0
Custom tables
cargo run -p colostle-cli -- --tables tables_en.toml # play in English
The tables compiled into the binary are core/tables.toml, which is the
French edition (identical to tables_fr.toml at the repository root).
tables_en.toml holds the English translation. A custom file replaces the
tables wholesale: it must contain every table with the correct number of entries,
or loading fails with a validation error. Use either root file as a template for
a new translation. Custom tables are a CLI feature; the web app always uses the
embedded tables.