No description
  • Rust 96.3%
  • CSS 3.6%
  • HTML 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-11-02 19:18:58 +01:00
.cargo feat: Add frontend and application code using Dioxus 2026-04-02 22:05:29 +02:00
.github/workflows refactor(frontend): drop the desktop and mobile build targets 2026-09-18 15:22:07 +02:00
backend refactor(frontend): replace Dioxus with Leptos, built by Trunk 2026-11-02 19:10:30 +01:00
cli feat: put the web app behind an account and give it real screens 2026-09-18 18:50:43 +02:00
core fix: satisfy the new clippy lints in Rust 1.99 2026-11-02 19:10:35 +01:00
crypto feat(crypto): add client-side encryption for zero-knowledge accounts 2026-09-18 18:50:02 +02:00
frontend refactor(frontend): replace Dioxus with Leptos, built by Trunk 2026-11-02 19:10:30 +01:00
.gitignore refactor(frontend): replace Dioxus with Leptos, built by Trunk 2026-11-02 19:10:30 +01:00
Cargo.lock fix: satisfy the new clippy lints in Rust 1.99 2026-11-02 19:10:35 +01:00
Cargo.toml feat(crypto): add client-side encryption for zero-knowledge accounts 2026-09-18 18:50:02 +02:00
CLAUDE.md refactor(frontend): replace Dioxus with Leptos, built by Trunk 2026-11-02 19:10:30 +01:00
Colostle_compressed.pdf feat(frontend): Add Dioxus desktop application with terminal UI 2026-04-02 01:12:35 +02:00
quick_start.md refactor(frontend): replace Dioxus with Leptos, built by Trunk 2026-11-02 19:10:30 +01:00
README.md refactor(frontend): replace Dioxus with Leptos, built by Trunk 2026-11-02 19:10:30 +01:00
rust-toolchain.toml refactor: finish TOML migration and consolidate save paths 2026-04-14 12:40:46 +02:00
rustfmt.toml refactor: finish TOML migration and consolidate save paths 2026-04-14 12:40:46 +02:00
tables_en.toml refactor: Update outdated function call 2026-03-31 10:25:09 +02:00
tables_fr.toml fix(tables): Correct some typing mistakes 2026-04-09 15:25:31 +02:00
TODO.md refactor: Update code quality 2026-04-05 00:04:38 +02:00
Trunk.toml refactor(frontend): replace Dioxus with Leptos, built by Trunk 2026-11-02 19:10:30 +01:00

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 — no unsafe blocks anywhere.
  • Clippy pedantic + nursery groups enabled at the workspace level (Cargo.toml [workspace.lints]), plus unwrap_in_result and panic warnings; dead_code and unused_imports are denied.
  • Zero warnings: CI runs cargo clippy --all-targets -- -D warnings.
  • Rustfmt: run cargo fmt before pushing. rustfmt.toml pins the style.
  • Pinned toolchain: rust-toolchain.toml selects the stable channel so local and CI builds agree.
  • Property tests: deck invariants live in core/tests/deck_properties.rs using proptest. 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 general eval.
  • 'unsafe-inline' for scripts is required by the inline module script Trunk writes into index.html to 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

  1. Draw cards equal to your exploration score (minimum 1)
  2. Look up each card in the encounter tables
  3. Red cards vs black cards determine outcome direction
  4. Follow-up events may trigger: hunts, gifts, Rookstone finds, ocean encounters, loot
  5. Write a journal entry
  6. 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

  1. Draw cards equal to your combat score
  2. Assign cards to your equipped weapon slots (exploration or combat damage)
  3. The Rook draws cards; highest total wins the round
  4. Wounds reduce the loser's relevant score
  5. 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.