Chapter 1 · the game

Game Rules — the actual mechanics

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.

The 300 format

live

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.

Battle live screen — progress ring and leaderboard
app-battle-live.png · progress ring + live leaderboard

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:

entry value = reps × tierMultiplier × (comeback? 1.2) × (lightning? 3)
match winner = max(Σ entry values) + closure bonus 15 if closer

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.

engine: packages/game-core/src/match.ts · apps/figma-app/engine.js scoreEntry / playerScore / playerRawReps / winner
proven by: packages/game-core/test/core.test.ts (29) · apps/figma-app/engine.test.js (23) · a full bot match in Bots → transcript

Tier handicap

v1 livev2 engine-ready

Fitness tiers multiply every rep, so the couch player's push-up is worth more than the athlete's.

Onboarding tier picker with four cards
app-onboard-tier.png · the tier pick at onboarding — 4 options
TierMultiplier100 raw reps score
Couch×1.5150
Casual×1.25125
Fit×1.0100
Athlete×0.8585

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.

effortMultiplier = 0.7 × (avgHrrPct / baselineHrrPct) + 0.3 × tierMultiplier

Anti-sandbagging: baselines drift-correct (≤10% per update) and volume-based tier correction catches a "couch" player doing 5× everyone's reps.

engine: packages/game-core/src/handicap.ts · baseline.ts · engine.js TIER_MULTIPLIERS / effortMultiplier
proven by: core.test.ts handicap cases · interactive demo on the public site (/)

Comeback ×1.2

live

More than 30% behind the leader in raw reps → your next entry is tagged ×1.2. Once per player per match.

Comeback armed banner on the battle screen
app-comeback.png · comeback banner armed (⚡) on the battle screen

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.

engine: packages/game-core/src/comeback.ts · engine.js COMEBACK_THRESHOLD=0.3 / comebackEligible / applyComeback
proven by: comeback tests in core.test.ts + gfamily.test.ts · fired live in the bot transcript

Closure bonus +15

live

The player whose raw total hits the target first gets +15 adjusted points — urgency for the closer, but never enough to beat real effort.

Final result screen with medals
app-result.png · final standings, medals, closer highlighted

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.

engine: engine.js CLOSURE_BONUS=15 / finalStandings / winner · game-core match.ts
proven by: engine.test.js closure cases · e2e.mjs asserts the bonus lands

Power-ups

live (app)

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

Lightning round active on battle screen
app-pwr-lightning-live.png · a live lightning window counting down on the battle screen
CardRarityEffect (exact)
Lightning Roundlegendary · 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 Stealepic · 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.
🛡 Shieldcommon · 50%Blocks one rep steal against you, then breaks. A blocked steal never fires: the shield is consumed, the thief keeps their card.
Time Freezerare · 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.

engine: engine.js POWER_UPS / DROP_ODDS / activatePowerUp / stealPreview / lightningActive (figma-app only — deliberately not in game-core yet)
proven by: engine.test.js power-up suite · e2e.mjs steal-blocked + lightning walk

Danger zone + the daily close

live

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.

Day one calm state
game-d13-calm.png · day 1 — calm, no chrome
Danger zone level 1 chip
game-dz1.png · DZ1 — gold chip, ≤3h
Danger zone level 2 banner
game-dz2.png · DZ2 — orange banner, ≤1h
Danger zone level 3 red banner and screen wash
game-dz3.png · DZ3 — red banner + screen wash, ≤30min
LevelTriggerUI escalation
DZ1≤ 3h leftGold countdown chip switches to urgency colour
DZ2≤ 1h leftOrange banner pulses: "DANGER ZONE — UNDER AN HOUR LEFT"
DZ3≤ 30min leftRed 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.

engine: apps/figma-app/daily.js dangerLevel / dzCopy / deadlineClock / closeDay / dailyStandings
proven by: e2e-daily.mjs — the whole ramp exercised with injected time (time travel) in seconds, zero console errors

Daily recap

live

The morning-after card: who won the day, by how much, whether you held the lead, and when the next day opens.

Recap screen - you won the day
game-recap-won.png · you took the day
Recap screen - Sam took the day
game-recap-sam.png · Sam took it — rivalry framing
Day close toast and day 2 begins
game-day-close.png · day closes → DAY 2 starts

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.

engine: daily.js recapFor / ledUntil / dueDays · rendered by app.js recap screen
proven by: e2e-daily.mjs day-1 and day-2 closes assert both recap variants

Seasons

live

4-week series: 3/2/1 points per match + MVP bonus, champion crowning, A/B division relegation-promotion, streaks with charity forgiveness.

Season ladder screen with points table
app-season-ladder.png · the season ladder — points, played, MVPs

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.

engine: packages/game-core/src/season.ts createSeason / recordMatch / seasonLadder / forgiveStreak / endSeason
proven by: packages/game-core/test/season.test.ts (15)

Charity pot

live (pledge ledger)

Players chip in; the winner directs the pot to a charity — never receives it. Stakes without gambling.

Charity pot on the result screen
app-pot.png · pot total on the result screen
Winner designates a charity
app-pot-designated.png · winner designates where it goes

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.

engine: game-core/src/pot.ts · engine.js createPot / contribute / designate
proven by: pot cases in core.test.ts · live in the bot transcript ($10.00 banked)

Verified reps

live

Entries can be machine-verified: camera-counted reps or heart-rate-strap effort. Standings show each player's verified %.

Camera verification note on the log sheet
app-camera-note.png · the camera-verify affordance on logging

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.

engine: engine.js standings verifiedPct · apps/web/src/verify/
proven by: verified-entry tests + the ! grammar in bus.test.ts
← backIndex