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 the engine, 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).

SOT bots are in flight β€” a dedicated agent is porting the grammar to the Engine V4 daily model; the spec'd surface is documented below the running one. The commands currently live in CI are the 300-era grammar the v1–v3 products speak.

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

The SOT grammar β€” daily model

in flight Β· tested

The Engine V4 port of the command surface, built as SotCommandBus β€” parallel to the legacy bus, zero shared state, so the 300-era bots above stay untouched and green while this lands (work was uncommitted when this page was written; the grammar below is read from the code + its test suite).

CommandWhat it does (daily model)Engine call underneath
new [target]Create the group + weekly season β€” defaults: 200 adjusted, Mon–Fri battle days, tier handicap; the card prints the tierβ†’physical-target table (couch 134 / casual 160 / fit 200 / athlete 236)season scaffold on the shared engine
join [tier]Join / re-tier between days β€” physical-target card per joiner; mid-day joins held for the next day (engine days snapshot their roster)roster + tier multiplier
startOpen today's battle β€” β‰₯2 players, play-day check, deadline next 21:00; deals the prototype power-up kit (one canon card each per day)createDay
log <ex> <reps>[!]Log a set β€” reply = banked reps + remaining + position; first-to-target fires the DAILY WIN card ("the battle continues β€” bank your day"), later completions get the banked-day card with streak projectionlogSet
sPer-player target status (πŸ† WON / 🏦 BANKED / % / failing-if-it-holds) + season points + streaksday standings + season record
day closeThe recap β€” winner, banked, failed, shielded, streaks (refuses before the deadline; force is ops-only). Days also auto-close at the deadline, recap prepended to the next replycloseDay β†’ dayRecordFrom β†’ recordBattleDay
season [ladder|end]Weekly standings 1:1 (1 Daily Win = 1 point); end locks the season + resolves the stakebattleStandings / endBattleSeason
stake <type> <terms> Β· agree Β· decline Β· stake doneFull stake lifecycle β€” dinner/dare/deliverable/charity, agreement-gated (active only when ALL accept, decline voids), resolves at season end; fulfilment notedstake lifecycle on the engine
pot <points> Β· charity <name> Β· donateCharity contributions in POINTS (trial currency), sealed until the stake is active; the winner directs the pot, donation minus the disclosed feecharity stake calls
lightning Β· steal @name Β· shield Β· freeze Β· bomb @name Β· rope @name Β· cardsSOT canon: Γ—3/10min Β· pure gain ("you GAIN X, they keep theirs", does not count toward target) Β· group streak protection consumed at the close it saves Β· +30min group-wide Β· +20/10min with defusal bonus Β· 50-credit to an inactive mate Β· the handactivatePowerUp per canon semantics
helpThe grammar in SOT language: reps, Daily Win, bank your dayβ€”

Push (not pull) β€” the SOT Β§1.3 list the chat layer must deliver unprompted: rep and milestone updates Β· lead changes Β· power-up activations Β· final-hour warnings Β· winner and season announcements Β· invitations and deep links Β· status requests Β· challenges and reactions Β· direct rep logging where the platform API permits.

Simulate it now: both bots carry a --sot sim flag (bun apps/bot-whatsapp/main.ts --sim --sot, same for Slack) β€” a fake clock walks the founder's demo in seconds: create β†’ joins β†’ stake agreements β†’ the race β†’ Daily Win β†’ bank β†’ auto-close recap β†’ ladder β†’ pots β†’ a full day-2 power-up pass.

lives: packages/bot-core/src/{sot-bus,sot-cards}.ts Β· test/sot-bus.test.ts Β· sims in apps/bot-whatsapp + apps/bot-slack
engine: apps/sot-engine.js β€” the same shared daily engine the v4 app drives
status: in flight (uncommitted at time of writing) β€” bot-core at 89 tests green with the SOT suite in

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
← backVersions