This is the architecture and design-decisions notebook for ScrabbleBot. If you're only writing a bot, see README.md and /docs instead.
| Where | What it is | Stack |
|---|---|---|
spacetimedb/ |
The whole game — schema, reducers, scheduled ticks, dictionary | Rust → WASM via SpacetimeDB |
src/ |
Spectator + admin + team UI | Vite + React + react-router-dom |
bot-starter/ |
Reference bot client | Node + TypeScript + SpacetimeDB SDK |
spacetime.json / spacetime.local.json |
Project config (module path, db name, dev client command) | — |
vercel.json |
SPA-rewrite rules for the deployed UI | — |
Everything talks to one SpacetimeDB database (scrabblebot on maincloud).
There is no separate backend or auth server.
humans (CLI / browser)
│
▼
┌─────────────────┐
┌──▶│ reducers │──┐
│ │ (lib.rs) │ │
│ └─────────────────┘ │
│ ▼
│ ┌─────────────────┐
│ │ tables + views │
│ └─────────────────┘
│ │
│ ▼
└────────────── clients (subscribe)
• bot processes
• spectator UI
Three distinct identity flavors, all SpacetimeDB Identity values:
| Flavor | Source | Stored where |
|---|---|---|
| Web identity | Anonymous, issued by SpacetimeDB to the browser on first visit | localStorage scrabblebot-token |
| Human identity | The developer's spacetimedb.com identity via spacetime login |
Used as ctx.sender() for CLI reducer calls |
| Bot credential | Anonymous, issued to a bot process when it first connects | Persisted on disk by the bot (.token-<name>) |
The three are wired up by two link tables:
HumanLink { web_identity → human_identity }— written by theconnect_idreducer when a developer runs the linking command from CLI.BotCredential { identity → bot_id }— written byclaim_credentialwhen a fresh client redeems a one-timeCredentialNonce. Many credentials can act as the same bot; old ones keep working when new ones are added.
A Bot is keyed by its own u64 id and is decoupled from any specific
identity. This is the key trick: humans can re-issue tokens for the same
bot persona indefinitely without ever "losing" the bot.
Matches aren't started manually. There's a single rolling Lobby at all times:
initopens the first Lobby and scheduleslobby_timeout_tickatnow + LOBBY_DURATION_SECONDS(60s).- Bots call
join_lobby()— they're recorded inLobbyMember. - If 6 real bots join, the lobby resolves immediately into a Match.
- If the timer fires first,
lobby_timeout_tickpads the roster up to 6 with idle simulated bots and resolves. - The resolving step in both cases is
resolve_lobby(): marks the lobbyResolved, starts the match (start_match_with), and callsopen_lobby_or_create()to spawn the next one.
The cycle is self-sustaining as long as there are at least 2 participants
(real or sim) available at resolve time. If fewer, the lobby is
Cancelled and a fresh one opens.
Lobby auction_type is currently hardcoded to Vickrey in
open_lobby_or_create. Easy knob to expose later if we want to alternate.
Each match has its own row in Match. State is fully per-match:
MatchParticipant { match_id, bot_id, balance, score }— replaces what used to live onBot(we're a multi-match game).BagLetter { match_id, letter, remaining }— each match has its own bag, drawn viactx.rng()for determinism.Auction { match_id, letter, status, opens_at, closes_at }— one open auction at a time per match. The next auction is scheduled byauction_tick.PendingBid { auction_id, bidder_bot_id, amount }— private table. Bots can't subscribe, so sealed bids stay sealed.AuctionResult { auction_id, winner_bot_id, top_bid, paid }— public history.Holding { match_id, bot_id, letter, count }— private table. Themy_rackview exposes only the calling identity's holdings, filtered viaBotCredential→bot_id.WordPlay { match_id, bot_id, word, base_score, bonus, total_reward }— public.
auction_tick is a self-rescheduling scheduled reducer that runs every
AUCTION_DURATION_MS (1s). It closes the current auction (computes winner
and paid according to AuctionType), credits the rack, opens the next
auction, and on bag empty calls on_match_ended(match_id).
on_match_ended does two things:
- ELO update —
update_elo_at_match_endruns pairwise: for each pair(i, j)of participants sorted by score, compute expected probability from current ratings, observe the outcome (1/0/0.5), accumulate a delta per bot, apply averaged over(n − 1)opponents. K=32. - Tournament hook — if the match was part of a tournament, advance the tournament (award Swiss points, eliminate bracket losers, etc.).
Three phases on a single Tournament row:
- Swiss:
start_tournamentpairs all bots randomly into matches of sizematch_sizefor round 1. Subsequent rounds sort byswiss_pointsand pair adjacently. Points awarded per finishing position (1st =n, last = 1). - Bracket: after the configured number of Swiss rounds, top
top_cutadvance. Each round eliminates the last-place finisher per match. Continues until only 2 remain. - Final: best-of-3 between the last 2. Aggregate score across all 3 games determines the winner.
TournamentMatch links Tournament ↔ Match rows with round and
phase. The advancement logic lives in on_match_ended and the
advance_tournament / start_swiss_round / start_elimination_round /
start_final_game helpers.
PendingBid is not declared public. SpacetimeDB clients can't
subscribe to private tables, so bidders see only their own bid as
acknowledged client-side — they can't snoop on the others. The auction
resolves entirely inside the module, then the result is written to the
public AuctionResult.
For real privacy, we'd also need to prevent bots from inferring opponent bids via subscription deltas, but for a hackathon this is sufficient.
| View | Returns | Purpose |
|---|---|---|
my_rack |
Vec<Holding> |
The caller's letters across all matches they're in (private to caller) |
my_team |
Option<MyTeam> |
The calling human's team summary (resolves browser→human via HumanLink) |
my_nonces |
Vec<CredentialNonce> |
Nonces the caller has minted (private to caller) |
my_admin |
Option<Admin> |
Caller's admin row if present |
Views are how we let clients query data that depends on the caller's identity without exposing it to everyone. Two helpers handle the browser-vs-CLI ambiguity:
fn resolve_human(ctx: &ReducerContext) -> Identity;
fn resolve_human_view(ctx: &ViewContext) -> Identity;Both look up HumanLink by ctx.sender(); if the caller is a linked
browser session, return the linked spacetime.com identity, otherwise
assume the caller already is the human.
| Reducer | Caller | Notes |
|---|---|---|
connect_id(web_identity) |
Human (CLI) | Writes a HumanLink |
create_team(team, bot) / join_team / leave_team / promote_to_owner |
Human | Resolves caller via resolve_human |
mint_credential_nonce() |
Team member | Writes a CredentialNonce |
claim_credential(code) |
Fresh anonymous client | Writes a BotCredential |
join_lobby() |
Bot credential | Looks up caller_bot_id via BotCredential |
submit_bid(auction_id, amount) |
Bot credential | Idempotent — replaces any earlier bid by this bot on this auction |
submit_word(match_id, word) |
Bot credential | Validates dictionary + ownership, deducts letters, credits score |
bootstrap_admin() |
Anyone (first time only) | Allowed iff Admin table is empty |
add_admin / remove_admin |
Admin | Caller checked via require_admin |
spawn_simulated_bot(name, strategy) |
Admin | Bypasses team flow — creates Bot + BotCredential with a fabricated identity |
start_tournament(...) |
Admin | — |
Reducers that move shared state are admin-gated via require_admin(ctx)?
at the top of the function.
init runs on every fresh database (--delete-data on-conflict during
dev republishes counts). It seeds:
- Admins from
SEED_ADMIN_HEX(currently just Tyler) — so dev wipes don't lock us out of/admin. - Simulated bots —
Cheapo,Valor,Brutus,Hagrid,Maverick,Snippet— the pool used to pad lobby timeouts. - The first lobby — kicks the perpetual lobby cycle into motion.
To add yourself as a permanent admin, add your spacetime.com identity hex
to SEED_ADMIN_HEX in lib.rs.
src/connection.tsx owns the single SpacetimeDB connection and exposes
it via React context (useConn()). It subscribes to all tables/views and
maintains a version counter that increments on any row change, giving
pages reactive re-renders without per-table state.
Pages:
| Route | What it shows |
|---|---|
/ (Home) |
Open lobby panel + running matches + recently completed |
/matches |
List of all matches (running + ended) with top scorer |
/matches/:id |
Live spectator for one match — current auction with countdown, recent auctions, per-bot rack + balance + score (reconstructed from public events), words played |
/team, /team/new |
Team management; "Generate token" button does mint-nonce → fresh anon connect → claim-credential → display token, all behind the scenes |
/account |
Web identity hex + connect_id CLI instructions; or linked-human view |
/leaderboard |
ELO ratings |
/tournament |
Current tournament's Swiss standings + bracket; drill-down to /matches/:id |
/docs |
Bot-writing guide |
/admin |
Admin-only: manage admins, launch tournament. Hidden from non-admins. |
Reconstructing opponents' racks: src/util.ts has reconstructRacks()
which folds AuctionResult + WordPlay events into a Map<bot_id_hex, Map<letter, count>>. Symmetric with what bots can derive from the same
public events.
bot-starter/src/index.ts is the reference implementation:
- Loads
BOT_TOKENfrom env or.token-<BOT_NAME>on disk. - On
claimCredential-needed (first run withBOT_NONCE), persists the fresh token automatically. - Subscribes to all tables, resolves
myBotIdviaBotCredential. - On connect:
joinLobby(). - On
match_stateupdate whereEndedand this bot was a participant:joinLobby()again (perpetual loop). - On
auction.onInsert: callsdecideBidfromstrategy.tsand submits viasubmitBid. - On
my_rack.onUpdate: callschooseWordfromstrategy.tsand submits viasubmitWord. Debounced to one attempt per 500ms per match.
src/strategy.ts is the only file participants are expected to edit.
npm install # web
npm run dev # spacetime dev + Vite togethernpm run dev runs:
spacetime dev --server maincloud --delete-data on-conflict -ywhich watchesspacetimedb/, rebuilds the module, publishes to maincloud on every change (wipes data only on breaking schema changes).- The Vite client (via
dev.runinspacetime.json).
For local development, use npm run dev:local (requires
spacetime start running in another terminal).
Set in spacetime.local.json. Currently scrabblebot. To rename in
place on maincloud (preserving data): spacetime rename <db-identity-hex> --to <new-name>.
spacetimedb Cargo crate, npm spacetimedb, and the spacetime CLI
must all be the same major version (currently 2.2.0). A version mismatch
manifests as TypeScript generated bindings using name: while the SDK
expects accessor: for IndexOpts — typecheck fails immediately.
- Module → maincloud:
npm run publish(one-shot) ornpm run dev(continuous). - Web UI → Vercel:
npx vercel --yes --name scrabblebotfrom repo root. SPA-rewrite is configured invercel.jsonso direct URLs to/team,/matches/123, etc. work. Env vars (VITE_STDB_HOST,VITE_STDB_DB) come from.env.production— defaults to maincloud + scrabblebot.
- No SpacetimeAuth integration. We bridge to spacetime.com identity
via a CLI
connect_idcall instead of running an OAuth flow. - No backend. All logic lives in the SpacetimeDB module; the React app talks to it directly via WebSocket.
- No bot rotation/retire. "Rotating credentials" means adding a new one alongside the old one. The bot persona survives forever.
- No human play. Bots only.
auction_tickandlobby_timeout_tickare callable by any client (not just the scheduler). Fine for a hackathon — for production, gate on the scheduler's identity.- The dictionary is the public-domain ENABLE list. Real Scrabble play
uses TWL (US) or SOWPODS (international); drop one in at
spacetimedb/wordlist.txt(sorted, uppercase, one word per line) if you have a license. - We don't handle bot crashes inside a match — the bot's slot just goes silent. The match continues; that bot just won't bid or play words.