The repo is still html-boggle; the app itself is Huddle.
A realtime multiplayer party-game suite for the web — rendered with Flutter (Yaru theme), networked with iroh gossip (Rust → WebAssembly), tooled with mise and Deno. No server component: two people entering the same room code land on the same gossip channel with nothing but public pkarr relays in between.
+-----------------------------------------------------------------+
| browser A browser B |
| Flutter app (Dart, Yaru UI) Flutter app (Dart, Yaru UI) |
| | dart:js_interop | dart:js_interop |
| glue.js <----------------------> glue.js |
| | wasm-bindgen | wasm-bindgen |
| iroh gossip (Rust wasm) <==gossip==> iroh gossip (Rust wasm)|
| | (relayed, end-to-end encrypted via n0 relays) |
| +-- pkarr PUT/GET of the room's member list --+ |
| https://dns.iroh.link/pkarr (public infra) |
+-----------------------------------------------------------------+
Huddle is a shell for pick-and-play party games — the round starter picks one; the choice
rides along in the start message so everyone lands in the same game. Each game has a short
description + how-to guide right in the room's picker. Every game also works with a single
player (join a room alone) so you can test each one without friends:
- Boggle — the original: find words in the 4×4 board (3-minute rounds, classic scoring, fresh leader-dealt board every round).
- Scattergories — the round starter serves a random letter; everyone submits dictionary words starting with it. Duplicates cancel at the reveal (classic rule); scoring is deterministic on every client.
- SketchIt — one player draws a secret word on a live canvas (strokes stream to everyone, rendered with perfect-freehand smoothing and pressure simulation; brush sizes + colors) while the others type guesses; the exact answer scores for both the guesser and the drawer. The drawer rotates each round.
- Chess — full rules via a pure-Dart engine: castling, en passant, promotion with a piece picker, checkmate, stalemate, threefold repetition. Two players; everyone else watches as a spectator. Moves are a replicated log, so everyone sees the same board. Solo practice plays both sides.
- Go (9×9) — classic Go: place stones, surround groups to capture, simple ko. Two consecutive passes end the game; area scoring (stones + surrounded territory) decides it. Two players, others spectate.
- Word Tiles — scrabble-style crossword on an 11×11 board: a 7-tile rack per player (drag or tap tiles onto the board), the opening play must cover the center star (doubled), a 7-tile play earns +50, and every formed word must be in the dictionary. Everyone in the room plays; the bag order is served at round start, so racks and scores derive identically on every client.
The shell adds the juice: synthesized sound effects (bundled WAVs, generated by
scripts/gen_sfx.ts, mutable from the room menu), patterned haptics on mobile, a QR code
dialog for join links, and confetti/animations on awards.
Every client keeps its identity and room cached locally: the iroh secret key, your name,
and the last room code persist in localStorage, so a closed tab (even a crashed one) can
rejoin the room as the same player and resync the whole game state from the host's snapshots.
"Leave room" forgets the cached room.
The game originally shipped on raylib (C + emscripten); Flutter replaced it for first-class mobile and accessibility support: real text fields summon the native virtual keyboard, touch targets follow platform conventions, and the semantics tree gives screen readers and tests a real DOM to navigate. The UI uses the native Yaru themes (Ubuntu's design system) with the full accent-color palette: pick any Yaru accent and dark/light from the lobby or the theme menu, and the choice persists across visits. Layouts adapt to phone-sized screens (stacked board, scrollable panels), and browser touch zoom is disabled so pinches and double-taps never resize the game.
Two people entering the same room code are connected to the same gossip channel with zero setup, because everything is derived deterministically from the room code:
- Topic:
topic id = sha256("topic:" + ROOM)→ iroh gossipTopicId. Same code, same topic. - Board: whoever starts a round deals a fresh random board (shuffled classic Boggle
dice, one die carries "Qu") and serves it to everyone in the
startmessage; state snapshots carry it too, so late joiners and reconnectors adopt the same board. Rejoining a room never replays an old board - you get whatever the current round is playing. - Rendezvous (serverless): a second keypair is derived from the room code
(
sha256("boggle-room:" + ROOM)). Every player publishes the ids of everyone they know to be alive as TXT records in that keypair's pkarr packet on the public pkarr relay (https://dns.iroh.link/pkarr), and polls the same packet for new ids to dial. Reads come before writes so concurrent publishers converge instead of masking each other. No server of ours is involved — the pkarr relays are the same public infrastructure iroh itself uses for address lookup.
All actual game traffic (presence, word claims, awards, scores, round control) flows over the gossip channel — encrypted end-to-end, relayed through the public n0 relays because browsers can't open UDP sockets. The pkarr packet only carries node ids at join time.
Roles: the member with the lexicographically smallest node id is the host (deterministic, so everyone agrees). The host starts rounds, arbitrates word claims, and publishes authoritative snapshots. All clients run identical rules, so when the host leaves the next smallest node id seamlessly takes over.
mise trust # first time only (or `mise trust` on first run prompt)
mise install # deno 2, rust 1.96, flutter 3.47, node 26
mise run setup # rust wasm target + vendored build inputs (wasm-bindgen-cli, word list)
mise run build # iroh wasm module + flutter web app -> dist/
mise run dev # build + watch + serve http://localhost:8000 (miniserve)
mise run serve # serve an existing dist/ build with miniserve
mise run test # e2e: two headless browsers join one room and play a word
mise run test-scatter # e2e: scattergories round, duplicate cancellation
mise run test-sketch # e2e: sketchit draw + guess round
mise run test-chess # e2e: capture chess with a mid-game spectator
mise run test-resume # e2e: reload auto-rejoins the room; leave clears the cache
mise run test-go # e2e: go with a capture and pass-pass end
mise run test-wt # e2e: word tiles play through the star
mise run test-solo # e2e: single-player chess + goOpen http://localhost:8000 in two browser tabs (or two phones on the same wifi — miniserve
binds 0.0.0.0), enter the same room code in both, and play. Browsers need internet
access for the public iroh/pkarr relays. mise run test bootstraps playwright-core into
.cache/pw and needs a chromium headless shell (npx playwright install chromium-headless-shell).
Pushing to main builds the game with mise-action
(.github/workflows/ci.yml) and deploys dist/ to GitHub Pages. To enable it:
- Repo settings → Pages → Source: GitHub Actions (one-time).
- Push to
main.
The live game is at boggle.adonm.dev (the app is called Huddle; the domain predates the rename). The custom domain is
kept alive across workflow deploys by the CNAME file the build emits into dist/ (Pages
needs it inside the artifact). The site also works from any base path — the Flutter build
resolves its own <base href> at runtime — and is fully multiplayer on static hosting: the
game has no server component. There is also a manually-triggered
e2e.yml that runs the full two-browser test in CI.
| Path | What |
|---|---|
mise.toml |
pinned tools + setup / build / dev / serve / test tasks |
app/lib/game.dart |
the room shell: presence, chat, ready flow, host election, state snapshots |
app/lib/games/ |
one GameLogic per game (boggle_logic.dart, chess_logic.dart, ...) + the shared turn_game.dart base |
app/lib/screens/ |
one file per screen (home, lobby, room, results, shared) and per-game play views under screens/play/ |
net/ |
Rust crate: iroh 1.0 + iroh-gossip 0.101 → wasm-bindgen, pkarr rendezvous |
glue/glue.js |
bridge: pkarr discovery, Dart↔iroh event plumbing, persisted identity |
scripts/ |
setup.ts, build.ts, dev.ts, gen_sfx.ts, gen_icons.ts (pure Deno), e2e-*.mjs (playwright) |
Messages are JSON strings broadcast on the room's gossip topic. Every message carries a monotonic sequence number — iroh-gossip's PlumTree dedupes by content hash, so identical repeats (periodic hellos, claim retries) would otherwise be dropped.
| Message | Who | Purpose |
|---|---|---|
hello |
everyone, every 5 s | presence, name exchange |
bye |
everyone, on leave | farewell (also gossip NeighborDown) |
start |
host | round start with deadline (unix ms) |
claim |
players | propose a word |
award |
host | word accepted: word, points |
reject |
host | word denied: reason = taken / invalid |
state |
host | authoritative snapshot (members, scores, words, phase, deadline) — sent when membership changes, every 10 s during play, and at round end |
wantState |
unsynced joiners | explicit snapshot request, retried every 2 s until someone answers (gossip links can be transiently one-way) |
sgSubmit |
players | scattergories submission (word) |
sketchStroke / sketchClear |
drawer | canvas stroke deltas (id, color, width, pts) / clear |
sketchGuess / sketchSolved |
guessers / host | a guess; the host arbitrates the exact match and broadcasts the solved word |
chessMove |
current player | from, to (e.g. e2/e4) appended to the replicated move log |
goMove / goPass / goResign |
current player | a stone coordinate, a pass (two in a row end the game), or a resignation |
wtMove / wtPass |
current player | tile placements [x, y, letter] (validated + scored deterministically) or a pass |
Scoring is classic Boggle: 3–4 letters = 1, 5 = 2, 6 = 3, 7 = 5, 8+ = 11. Rounds are 3
minutes; scores accumulate, found words reset each round. Word validation uses a public-domain
word list (dwyl/english-words, filtered to 2–16 letters, sorted; fetched at build time by
scripts/setup.ts, not stored in the repo).
- Browser iroh is relay-only (no direct hole-punching). Traffic is end-to-end encrypted but transits the public n0 relay servers. Everyone pins the same relay so runtime home-relay changes can't silently drop live connections.
- The room's pkarr packet is signed with a key anyone who knows the room code can derive — same trust model as the room code itself. Anyone who knows a code can join that room.
- Word submissions are optimistic on non-host clients and host-arbitrated; claims are retried
until acknowledged and host snapshots self-heal state after connection drops. Your iroh
identity (secret key), name, and last room persist in
localStorage, so reloading or returning later rejoins the room as the same player; "Leave room" clears the cache. Fresh joiners stay quarantined from hosting until they adopt a snapshot, and keep asking for one until a synced player answers, so a late spectator can never regress a live game. - Sketch strokes are ephemeral: a mid-round reconnect resyncs the word/drawer/scores but not the in-progress canvas.
- The dictionary is client-side; there is no anti-cheat. This is a party game.
Licensed under the Apache License, Version 2.0 (see LICENSE). The word list (fetched at build time from dwyl/english-words) is public domain.