Chapter 3 · the bots

Bots — one grammar, two platforms

The whole game is playable inside a group chat. A platform-agnostic CommandBus (packages/bot-core/src/bus.ts) takes plain text in and returns WhatsApp/Slack-compatible text cards out — all game logic lives in game-core, so the bots are a thin parse → mutate store → format layer. The identical command surface runs on WhatsApp (via the local Qalarc Hub) and Slack (Bolt Socket Mode).

Architecture

live
  WhatsApp group ──▶ Qalarc Hub (localhost:8769) ──▶ apps/bot-whatsapp/main.ts ─┐
                                                                                ├─▶ CommandBus.handle()
  Slack channel ───▶ Slack Bolt (Socket Mode) ────▶ apps/bot-slack/app.ts ─────┘         │
                                                                                          ▼
                                                                    parse() → normalise token → alias map
                                                                                          ▼
                                                                    MatchStore (.data/bot-matches.json, one match per chatId)
                                                                                          ▼
                                                                    game-core engine (logReps · standings · comeback …)
                                                                                          ▼
                                                                    cards.ts → text card (mrkdwn, renders on both platforms)
                                                                    card-image.ts → 1200×675 SVG result card → /cards/<name>.svg

Every command is pure input→output: handle() never throws (errors come back as cards), runs synchronously with canned taunts, and handleAsync() upgrades taunt and digest to AI-generated lines via the local AI endpoint with a 2s timeout and canned fallback.

lives: packages/bot-core/src/{bus,store,cards,card-image,digest,game-extras,ai}.ts · apps/bot-whatsapp · apps/bot-slack
proven by: bus.test.ts (27) + gfamily.test.ts (12) — 39 tests green in CI

Command surface

live

15 commands, identical on both platforms. Optional rwf / /rwf prefix for group-mention style. Aliases: s→standings · h→help · again/runitback→rematch · nem→nemesis · monday→digest.

CommandWhat it doesMechanics underneath
new [target]Create a match (default 300)createMatch → status open, play days + exercise list announced
join [tier]Join with couch/casual/fit/athleteSets your ×1.5/×1.25/×1.0/×0.85 multiplier
startLock the roster, go livestatus → live, logging opens
log <ex> <reps>[!]Log reps; trailing ! = camera-verifiedChains applyComeback → entry with verified flag; closure check
sStandings with medal barsstandings() + ⚡ comeback markers + 👁 spectator count
taunt <name>AI-generated cheek (canned fallback)handleAsync → local AI endpoint, 2s timeout
pot <cents>Chip into the charity potcontribute() — "$5.00 banked. Winner picks where it goes."
resultFinal card + shareable imagefinalStandings() + closure bonus; writes the SVG card
rematchRun it back after a finishSame roster/rules, fresh pot — mirrors the app's one-tap rematch
nemesis [name]Who's got your numberHead-to-head record scan across stored matches
digestMonday recap cardMatches, margins, MVPs, pot, rivalry callout, AI one-liner
link <code>Bind this chat to a crewSame CREW-XXXX code the app's waiting room shows
watch <code>Spectate another crewRead-only standings of the watched crew in your chat
challenge <code>Crew-vs-crew challengechallenge accept locks it in
season new / ladderRun a season, climb the ladder3/2/1 points + MVP, A/B divisions (see Game Rules)
lives: the exact help card verbatim in packages/bot-core/src/cards.ts helpCard() · grammar in bus.ts COMMANDS / ALIASES / parse()

A real match, start to finish

simulated

Captured from the live /debug simulator (same CommandBus the real bots run) — a couch player steals one back from an athlete. Watch the comeback fire, the closure land, and the adjusted-score upset resolve.

Ben: new 150
🏋️ *Match created — first to 150 reps*
Exercises: Push-ups, Squats, Sit-ups, Burpees, Lunges
Play days: Tue · Thu
Ben: join couch        Nico: join athlete
✅ *Ben* in as *couch* (1 playing)
✅ *Nico* in as *athlete* (2 playing)
Tier matters — couch reps are worth 1.5×, athlete reps 0.85×. Effort wins.
Ben: start
🚀 *LIVE — first to 150 raw reps closes it*
Ben: log pushups 40
💪 *Ben* logs 40 Push-ups
Nico: log squats 80
💪 *Nico* logs 80 Squats
⚡ comeback ×1.2 applied — never out of it.   ← Ben is 50% behind → eligible
Ben: s
🏋️ *Standings* (LIVE)
🥇 *Nico* █████░░░░░ 53.3%   raw 80 · *adjusted 81.6* · 0% verified
🥈 *Ben*⚡ ███░░░░░░░ 26.7%   raw 40 · *adjusted 60* · 0% verified
Ben: log pushups 110
🔥 *Ben* logs 110 Push-ups — *THAT'S 150! MATCH CLOSED* 🏁
⚡ comeback ×1.2 applied — never out of it.
Ben: result
🏁 *MATCH RESULT*
🏆 *Ben* takes it — adjusted score *273*
1. Ben — 273 (150 raw)      ← 60 + 110×1.5×1.2 (=198) + 15 closure
2. Nico — 81.6 (80 raw)
🖼 Result card: …/cards/m-wiki-txt-….svg

The same walkthrough is replayable any time at /debug — the console feeds the real bus through /api/sim.

lives: apps/debug (page) · serve.ts /api/sim (isolated scratch store .data/sim-debug.json) · apps/figma-app/bots-sim.mjs (scripted replay + hub check)

The simulator console

live
Debug page with bot simulator mid-match
ops-debug.png · /debug — live bot simulator, mid-match (3 players, pot building)

What. The /debug page is a chat-shaped console into the real CommandBus: type commands as any player, watch the cards come back, and the generated result card SVG appears inline. This screenshot was taken mid-match with three tiers playing and a $10.00 pot banked.

How it works. Posts to /api/sim which runs simBus.handle() — one bus, one scratch store, overridable chatId so automated runs never collide with the console's session. The page doubles as the element gallery for design review.

lives: apps/debug/ · serve.ts /api/sim
proven by: this very screenshot was produced through the endpoint

Result cards

live

Every finished match can be shared as a 1200×675 branded image — SVG generated server-side by the bots, PNG export from the app.

Generated SVG result card for the transcript match
shots/bots-result-card-match.svg · the actual card from the transcript above (Ben 273 def. Nico)

How it works. card-image.ts renders the final standings, scores and match name into an SVG, writes it to .data/cards/, and the bots link it (serve.ts serves /cards/*). The link in the transcript above produced exactly this file. Photo finishes (margin ≤ 5%) get a dramatic coral variant; the app exports PNG for social.

lives: packages/bot-core/src/card-image.ts · game-core photo-finish.ts · served from .data/cards/
proven by: card generation tests + the card linked above

Rematch · Nemesis · Digest

live
  • Rematch (rematch / again / runitback) — after any finished match, one command re-arms the same roster and rules with a fresh pot. The app has the one-tap equivalent on the result screen.
  • Nemesis (nemesis [name]) — scans your head-to-head history and names the player who beats you most, with the record. Rivalry is the retention engine; this makes it personal. nemesis with no args finds your worst matchup automatically.
  • Monday digest (digest / monday) — the week's recap card: matches played, winning margins, MVPs, pot totals, a rivalry callout, and an AI one-liner to open the week (canned fallback if the AI is asleep).

These are the "G-family" second wave — first four shipped 27 Aug (docs/17).

lives: bus.ts rematch/nemesis/digest handlers · digest.ts · game-extras.ts
proven by: gfamily.test.ts (bot-core + game-core) — 27 tests across both suites

Running them for real

demo-only today

Status honestly: the bots are demo-reliable, not production-always-on. They run while the dev machine (superlocal) is up; heartbeats appear in the hub console (45s freshness window). WhatsApp flows through the local Qalarc Hub session; Slack needs its 5-minute app creation (blocker T1). Moving both to the always-on minirig is one SSH command away (blocker T3) — see Status & Roadmap.

lives: apps/bot-whatsapp/main.ts · apps/bot-slack/{app,main}.ts · heartbeat files .data/heartbeat-{whatsapp,slack}.json
← backApp Screens