Skip to content

Latest commit

 

History

43 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Huddle

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)             |
+-----------------------------------------------------------------+

Games

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.

Why Flutter

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.

The "automatic rigging"

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:

  1. Topic: topic id = sha256("topic:" + ROOM) → iroh gossip TopicId. Same code, same topic.
  2. 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 start message; 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.
  3. 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.

Quickstart

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 + go

Open 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).

Publishing (GitHub Pages)

Pushing to main builds the game with mise-action (.github/workflows/ci.yml) and deploys dist/ to GitHub Pages. To enable it:

  1. Repo settings → Pages → Source: GitHub Actions (one-time).
  2. 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.

Layout

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)

Gossip message protocol

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).

Notes & limitations

  • 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.

License

Licensed under the Apache License, Version 2.0 (see LICENSE). The word list (fetched at build time from dwyl/english-words) is public domain.

Releases

Packages

Used by

Contributors

Languages