Chapter 1 · the game
One rule set drives every surface: the engine is a pure TypeScript module (packages/game-core, spec) ported 1:1 into the app (apps/figma-app/engine.js) so the phone plays the same game the bots referee. Numbers below are read straight from the code.
A group agrees on exercises; any reps, any order, any mix. First player to the raw target closes the match — but the winner is the highest effort-adjusted score at closure.

How it works. Every logged entry is scored, then two different totals are tracked per player: raw reps (the plain sum, which drives closure — default target 300, a "Light" match is 150) and the adjusted score:
So an athlete can close on raw reps and still lose to a couch player who moved less but earned more per rep. This is the whole product thesis in one formula: effort wins the day.
Worked example (from a real bot match): Ben (couch) logs 40 then 110 push-ups; Nico (athlete) logs 80 squats. Ben's second entry fires the comeback boost. Ben: 40×1.5 = 60, 110×1.5×1.2 = 198, closure bonus +15 → 273. Nico: 80×0.85 = 68. Ben closed and won; against a fitter closer the upset is live.
scoreEntry / playerScore / playerRawReps / winnerFitness tiers multiply every rep, so the couch player's push-up is worth more than the athlete's.

| Tier | Multiplier | 100 raw reps score |
|---|---|---|
| Couch | ×1.5 | 150 |
| Casual | ×1.25 | 125 |
| Fit | ×1.0 | 100 |
| Athlete | ×0.85 | 85 |
v2 (engine-ready, waiting on straps): when an entry carries a measured avgHrrPct and the player has a learned baseline, the multiplier blends live heart-rate evidence with the declared tier — 70% measured, 30% declared. The camera/HR lane (Verification) already logs those fields.
Anti-sandbagging: baselines drift-correct (≤10% per update) and volume-based tier correction catches a "couch" player doing 5× everyone's reps.
TIER_MULTIPLIERS / effortMultiplierMore than 30% behind the leader in raw reps → your next entry is tagged ×1.2. Once per player per match.

How it works. Eligibility is recomputed on every log: (leader − you) / leader > 0.30 while the match is live and you haven't used it. The boost is tagged onto the entry at log time (not a global multiplier) so it composes — a comeback entry inside a lightning window scores ×1.2 ×3. The bot standings mark eligible players with ⚡ so the group can see it coming.
Mechanically this enforces the design promise: everyone has a genuine shot until the last set.
COMEBACK_THRESHOLD=0.3 / comebackEligible / applyComebackThe player whose raw total hits the target first gets +15 adjusted points — urgency for the closer, but never enough to beat real effort.

How it works. The first log that takes a player's raw total to ≥ targetReps flips the match to complete, stamps closedBy, and logging stops. Final standings = live standings + 15 to the closer, re-ranked. Winner and closer can be different players — the app and bot cards both show who closed.
CLOSURE_BONUS=15 / finalStandings / winner · game-core match.tsFour cards — Lightning, Rep Steal, Shield, Time Freeze. Drop odds by rarity: common 50 / rare 30 / epic 15 / legendary 5 (rarity is cosmetic in v1; it sets chip colour and drop odds only).

| Card | Rarity | Effect (exact) |
|---|---|---|
| ⚡ Lightning Round | legendary · 5% | Reps count ×3 for 10 minutes. One activation per player per match — the window can expire, the once-per-match flag never resets. |
| 🗡 Rep Steal | epic · 15% | Take 10% of the leading rival's raw reps (floor, min 1 while they're above zero) — instantly, both ledgers move. The stolen reps are logged as an entry, so your tier multiplier applies to them too. |
| 🛡 Shield | common · 50% | Blocks one rep steal against you, then breaks. A blocked steal never fires: the shield is consumed, the thief keeps their card. |
| ⏱ Time Freeze | rare · 30% | +30 minutes on the play-day deadline. |
House rules (enforced in code): activating spends the card, except a blocked steal; lightning stacks with a comeback entry (×1.2 ×3 = ×3.6); steal amount is previewed against the current leading rival before you commit. All power-up functions are pure — they return a new match state + a result card, never mutate.
Screens of every power-up surface (arsenal sheet, detail, steal toast, blocked toast, lightning toast) are in App Screens → power-ups.
POWER_UPS / DROP_ODDS / activatePowerUp / stealPreview / lightningActive (figma-app only — deliberately not in game-core yet)Matches run on play days with a nightly deadline. The final 3 hours escalate through three danger levels; at the deadline the day closes itself and a daily winner is crowned.




| Level | Trigger | UI escalation |
|---|---|---|
| DZ1 | ≤ 3h left | Gold countdown chip switches to urgency colour |
| DZ2 | ≤ 1h left | Orange banner pulses: "DANGER ZONE — UNDER AN HOUR LEFT" |
| DZ3 | ≤ 30min left | Red banner with live minute count ("…24 MINUTES LEFT"), heartbeat pulse on the timer, full screen wash — "LOG NOW" |
How it works. The deadline is the next 21:00 Australia/Sydney by default (Time Freeze extends it). A 1s app ticker recomputes the remaining time, the level, and the dual clock — group timezone (AEST) + your local — every second. At the deadline the day auto-closes exactly once: logging locks, the day's winner (highest adjusted among that day's entries only) is recorded to dailyHistory, a recap renders, and the next play day begins. Multi-day matches accumulate one winner per day.
dangerLevel / dzCopy / deadlineClock / closeDay / dailyStandingsThe morning-after card: who won the day, by how much, whether you held the lead, and when the next day opens.



How it works. recapFor(match, dayKey) builds the summary from that day's entry slice: winner + margin, "you led until…" tracking (if you led at any point and lost, the recap says so — the sting is the hook), pot growth since the previous close, and the next play day's opening time in your local timezone. The home screen shows a temporal state chip (day in progress / next day opens at…) via the same machinery.
recapFor / ledUntil / dueDays · rendered by app.js recap screen4-week series: 3/2/1 points per match + MVP bonus, champion crowning, A/B division relegation-promotion, streaks with charity forgiveness.

How it works. Each finished match feeds recordMatch: 1st place +3, 2nd +2, 3rd +1, MVP vote +1 (a second podium so effort gets recognised even without a win). Ladder sorts by points → wins → MVPs. At season end the top points total is crowned champion and divisions swap: bottom of A ↔ top of B.
Streak forgiveness: a streak about to break can be saved once per season by topping the charity pot up by ≥ $2 — preserved, not extended. No free rides, money never goes to a player.
createSeason / recordMatch / seasonLadder / forgiveStreak / endSeasonPlayers chip in; the winner directs the pot to a charity — never receives it. Stakes without gambling.


How it works. The pot is a pure ledger per match: contribute(pot, playerId, cents) appends, potTotalCents sums, designate(pot, charity) records the winner's choice at result time. In the bots, pot 500 chucks $5.00 in and the card reads "Winner picks where it goes". The money story stays a pledge ledger until the legal opinion lands (see Status — L1); employer-funded pots are the clean interim.
createPot / contribute / designateEntries can be machine-verified: camera-counted reps or heart-rate-strap effort. Standings show each player's verified %.

How it works. An entry logged through the camera verifier carries verified:true; a strap session attaches avgHrrPct, which the v2 handicap blend consumes. Standings compute verifiedPct = verified raw / total raw per player, surfaced on every leaderboard (app + bots). In the bot grammar, log pushups 25! marks the entry verified.
Full mechanics in Verification.
standings verifiedPct · apps/web/src/verify/! grammar in bus.test.ts