Bum’s Rush — Game Design & Implementation Document¶
Status: design, not yet implemented. Written 2026-08-08 against
main. Target: a 2–4 player physics party game at/bums-rush, in the tradition of Le Cartel Studio’s Heave Ho / Heave Ho 2 — you are a head with two long arms, you grab things, you swing, you form chains with your friends, you fall to your splattery end. Audience: the implementing agents. Every section is meant to be actionable without re-deriving decisions; §20 splits the work into tickets that can be handed out in parallel.
§0 How to read this¶
0.1 Source material, and how much to trust it¶
The design brief was “recreate Heave Ho 2”. The source material actually consulted, in descending order of reliability:
Source |
What it settled |
|---|---|
The Steam store page (supplied as HTML — the store itself is egress-blocked) |
Official copy, feature matrix, tags, accessibility list, “developers recommend a controller”, 2–4 players, online + shared-screen co-op and PvP, price/scope signal (~1.8h main story, $9.99) |
Reviews & previews (Game Informer, TheSixthAxis, Checkpoint, Quest Daily, MKAU, PowerUp!, Siliconera, Nintendo World Report — all read via search summaries; the sites are egress-blocked) |
Control scheme, world themes, level and Showdown counts, objective types, the item list, the assist cat |
The original Heave Ho (2019) coverage |
The grab/swing/chain fundamentals, respawn behaviour, the striped assist beams, the golden-rope minigames |
Where sources disagree, this document picks and says so. The clearest disagreement is level count: reviews variously report “about 60 levels” and “72 co-op levels across eight worlds, plus 56 randomized versus levels”. We target 72 co-op + 56 Showdown arenas (§6.6, §8) because it divides cleanly by eight and because a level here is cheap — the level format is data (§6.1), not code.
Nothing in this document is a claim about Heave Ho 2’s internals. Where it says “the source does X”, that is what coverage described; where it says “we do Y”, that is our decision.
0.2 Deliberate deviations, and why¶
A faithful port would be wrong for this site in five specific ways. Each deviation below is intentional; do not “fix” them back toward the source.
Solo is playable. The source requires two players. A visitor who lands on
/bums-rushfrom the games index is, statistically, alone. Every level carries aminPlayersfield; ~40 of 72 are authored solo-viable, and the solo player gets a Solo Ladder (time attack + leaderboard) over exactly those. Co-op-only levels are visibly marked and offer one-tap matchmaking rather than a dead end. See §6.7 and §9.7.Mobile is a first-class target. The source is a controller game on console and PC. Roughly half this site’s traffic is a phone in landscape, so touch is designed (§12), not bolted on — including an Auto-Grab input model that exists because two independent analog arms plus two grab buttons do not fit on two thumbs.
Progress follows the account. Steam Cloud’s equivalent here is our own tables (§10), including cross-device resume mid-session and a guest→account merge, because a browser game’s players routinely start signed out.
Nothing strobes. The source has a rave world. Ours does too (Marker Mosh), and it is beat-driven without ever flashing above the WCAG 2.3.1 threshold (§2.8). This is a design constraint on the world, not a toggle.
No crossover skins. The source unlocks skins from other Devolver games. We unlock our own cast (§2.5) and RMH Studios cosmetics. See 0.3.
0.3 Originality rules (binding on every implementer)¶
This is a homage built from mechanics, not a port. Mechanics, physics behaviour and genre conventions are not protectable and we recreate them deliberately. Everything expressive is ours:
No copied assets. No sprites, audio, fonts, UI, or level geometry from Heave Ho / Heave Ho 2 — including tracing screenshots. Every level in §6.6 is authored fresh against the pacing rules in §6.7.
No copied names. “Bum’s Rush” is ours (the idiom for being thrown out by the scruff — which is the game). Worlds, characters, items and cosmetics use the names in this document. In particular the source’s
Ima Rockandarm sprayitems ship here as Paperweight and Stretch Ink.No third-party characters, no Devolver cast, no “inspired by” skins of other studios’ properties.
Attribution, once, honestly. The credits screen carries a single line: “A physics party game in the tradition of Heave Ho by Le Cartel Studio. Unaffiliated.” That line is the only place the source is named in shipped UI, and it is a statement of lineage, not endorsement.
If a ticket in §20 seems to require copying something specific, that ticket is wrong — raise it rather than copying.
0.4 Definition of done for this feature¶
Beyond the repo’s standing gates (pnpm check:consistency, no new type/lint
warnings, docs/design-language.md §0), Bum’s Rush is done when:
Two strangers can reach a shared level from
/bums-rushin under 15 seconds with no account, no download, and no room code typed by hand.A player can leave mid-level and rejoin from a different device with cosmetics and progress intact.
The same level is completable with a gamepad, a keyboard, and two thumbs on a phone, without any of the three feeling like the fallback.
The
GAME_CAPABILITIESentry (§1.2) is true — every accessibility feature it claims exists in code. The honesty rule inlib/game-capabilities.tsis not negotiable; drop a claim rather than ship it aspirationally.
§1 Identity & registration¶
1.1 Names and ids¶
Thing |
Value |
|---|---|
Title |
Bum’s Rush |
Catalog / capability id |
|
Route |
|
Socket event prefix |
|
i18n namespaces |
|
Scoped CSS token group |
|
Level data root |
|
Component root |
|
Logic root |
|
1.2 Registry entries this game must land in¶
A new game is not one file. These are all of them; §20-T1 does them together so the game is never half-registered.
lib/catalog/games/bums-rush.ts (new file — the barrel imports
alphabetically, so nothing else changes):
import type { GameInfo } from '../types';
const entry: GameInfo = {
id: 'bums-rush',
order: 220, // next free slot — existing entries run 0…210 in tens
title: "Bum's Rush",
description:
'A hand-drawn physics party game: you are a head with two long arms. Grab, swing, and fling your friends across 72 levels — 2–4 players online or on one screen.',
longDescription:
"You are a head with two enormous arms and no legs whatsoever. Grab a ledge, swing, let go at exactly the wrong moment, and paint the wall. Bum's Rush is a hand-drawn physics party game for one to four players: link hands into a living rope, haul each other over gaps that none of you could cross alone, and argue about whose fault it was. Eight worlds, 72 co-op levels with hidden objectives, and a 56-arena Showdown mode for when co-operation has run its course. Plays online, on one screen, or both at once — with a gamepad, a keyboard, or two thumbs.",
href: '/bums-rush',
cta: 'Get a Grip',
isSteam: false,
gradient: 'from-amber-400 to-rose-500',
iconName: 'Hand',
color: 'from-amber-400/20 to-rose-500/20 hover:border-amber-400/50',
tags: ['Party', 'Multiplayer', 'Physics', 'Platformer'],
imagePath: '/images/games/Bums-Rush.webp',
authGate: false,
};
export default entry;
lib/game-capabilities.ts — GAME_CAPABILITIES['bums-rush']. The test file
holds this to exact key parity with the catalog, so it lands in the same commit:
'bums-rush': {
genre: ['party', 'platformer'],
players: ['single', 'online-coop', 'online-versus', 'async-leaderboard'],
maxPlayers: 4,
input: {
supported: ['keyboard', 'mouse', 'touch', 'gamepad'],
required: [], // playable with any one of the above
},
sessionMinutes: [10, 45],
engine: '2d-canvas',
demanding: false, // budget in §17 is a mid-range phone at 60fps
save: 'own-table', // §10.2 — its own models, not the shared blob
accessibility: [
'remappable-input', // §4.5
'assist-mode', // §4.7
'reduced-flashing', // §2.8 — by construction, no toggle needed
'colorblind-safe', // §2.8 — seat identity is shape + colour
'no-timed-input', // §6.7 — no level fails on a timer; par times are optional objectives
],
// no `descriptors` — comic ink-splat deaths are not `violence`, and nothing flashes
},
Honesty check. Each
accessibilitystring above is a promise about shipped code. T13 (§20) owns delivering all five. If one slips, delete the string, not the ticket.
Other registries, all one-liners:
File |
Change |
|---|---|
|
add |
|
add the |
|
|
|
12 entries, |
|
5 models (§10.2) + migration |
|
the |
|
catalog card art |
THEME_EXCLUDED_ROUTES needs no edit — components/Providers.tsx derives it
from games.map(g => g.href), so adding the catalog entry suppresses the site
theme on /bums-rush automatically. Likewise the docs generators
(pnpm docs:site) read the catalog; run them, don’t hand-edit their output.
1.3 The route¶
app/routes/bums-rush.tsx, following app/routes/void-breaker.tsx exactly —
lazy game import, gameRouteHead('bums-rush'), GameBackLink,
GameErrorBoundary, GameLoadingFallback. Do not hand-roll og:* tags;
buildMeta (through gameRouteHead) owns the whole Open Graph block.
The one departure from void-breaker.tsx: the root element is not
unconditionally fixed inset-0. Bum’s Rush has document-shaped screens (title,
world map, wardrobe, results) and a fixed-viewport screen (the level). Per
docs/design-language.md §12.1 rule 6, the shell switches:
Title / world map / lobby / wardrobe / results →
.app-pageAn active level or Showdown round →
.app-viewport
and resets window.scrollY on the transition into .app-viewport, because a
fixed viewport cannot undo a scroll offset it inherits.
§2 Art direction — the notebook¶
2.1 The conceit¶
The game is a flipbook someone drew in the back of a school exercise book during a very boring lesson. Not “cartoon”. Not “hand-drawn” in the vague sense. Specifically: cream paper with a faint ruled grid, biro and marker on top, margin doodles, corrections that were never erased, a coffee ring, tape holding a torn corner down.
This is load-bearing for three reasons and not just a look:
It makes cheap art read as intentional. A wobbling ink line is a style; a wobbling vector is a bug. Everything we cannot afford to animate richly, we animate as a drawing instead.
It gives the physics somewhere to be funny. Splatting against a wall is funnier as an ink blot on paper than as a gore effect, and it keeps the game family-friendly without a single content descriptor.
It is legible at phone size. High-contrast dark ink on light paper survives a 6” screen in daylight far better than the site’s glass tier does.
The game is exempt from the --site-* palette (it is a game route, theme
suppressed) but not exempt from shared behaviour: GameErrorBoundary,
GameLoadingFallback, the session hook, the viewport primitives in §12.1, and
the DOM chrome rules (rounded-site* where DOM chrome exists, no
transition-all, no tailwindcss-animate classes) all still apply. Canvas
pixels are ours; DOM is the site’s.
2.2 The --bum-* token group¶
Defined in app/globals.css alongside --temple-* / --slice-* / --neon-*,
with @theme aliases so Tailwind utilities exist (bg-bum-paper,
text-bum-ink, …). No raw hex inside components — that is CI-enforced for
site-tier files and is the convention here regardless.
/* Bum's Rush — a scoped variable group, in the spirit of --temple-* / --slice-*.
The game route suppresses the site theme, so these are absolute values, not
theme-tracking ones. Ink is near-black-blue rather than #000 because pure
black on cream reads as printed, not drawn. */
--bum-paper: #f4ead6; /* base sheet */
--bum-paper-2: #ece0c8; /* torn underlay / shadowed sheet */
--bum-paper-edge: #d8c9aa; /* sheet edge + fold */
--bum-rule: #b9c9d6; /* ruled lines */
--bum-margin: #e2a0a8; /* the red margin line */
--bum-ink: #1e2430; /* primary stroke */
--bum-ink-soft: #47506080; /* construction lines, hints, ghosts */
--bum-graphite: #6b7280; /* pencil under-drawing */
--bum-highlight: #fff6a8; /* highlighter — objectives, goal */
--bum-tape: #e8dcc0cc; /* sticky tape */
--bum-splat: #232b3a; /* death blot */
/* Seat colours: four marker pens. Chosen for distinct hue AND distinct
lightness, so they separate under protan/deutan/tritan simulation — but see
§2.8: colour is never the only channel. */
--bum-seat-1: #d1495b; /* red pen */
--bum-seat-2: #2a7fbf; /* blue biro */
--bum-seat-3: #3f8f52; /* green marker */
--bum-seat-4: #c9761a; /* orange */
Per-world accent overrides live in the level data (§6.1 palette), not in CSS —
a world tints the paper and the ink slightly (Vacuum Ward is graph paper in cold
grey; Sizzle Street is a grease-stained recipe card), and those are values the
renderer reads, not classes the DOM applies.
2.3 The paper & ink render pipeline¶
Canvas 2D (engine: '2d-canvas'). The whole look is six layers, drawn back to
front each frame. Everything expensive is baked once to an offscreen canvas and
blitted; only the ink layer is redrawn per frame.
# |
Layer |
Content |
Cost |
|---|---|---|---|
0 |
Sheet |
Paper base colour + fibre noise + ruled grid + margin line + coffee ring |
Baked once per world, tiled |
1 |
Under-drawing |
Pencil construction lines for level geometry — deliberately not aligned with the ink |
Baked per level |
2 |
World ink |
Level geometry, props, hazards as ink strokes |
Baked per level; re-baked only when a prop’s shape changes |
3 |
Actors |
Characters, arms, carried objects, drones |
Per frame — the only hot layer |
4 |
FX |
Splats, torn-paper confetti, speed scribbles, dust |
Per frame, pooled |
5 |
Chrome-in-world |
Sticky-note tutorials, doodle arrows, the goal’s highlighter wash |
Baked per level |
The boil. Hand-drawn animation “boils”: the line wobbles because each frame was drawn again. We fake it with a per-vertex offset sampled from a seeded noise field, advanced only every 3rd frame (a 20fps boil under a 60fps sim). Three rules:
The boil offset is a function of
(vertexId, floor(frame/3))through a seeded PRNG — neverMath.random()per frame, or the line hisses instead of boils.Amplitude is 1.4 px at design scale on world ink, 0.8 px on actors. More reads as an earthquake.
useReducedMotion→ boil amplitude 0 and the offset frozen at phase 0. The drawing still looks drawn (the strokes are tapered and imperfect); it just stops moving. This is also whatperf-litedevices get (§17).
Stroke style. Every ink stroke is drawn twice: a --bum-graphite pass at
40% alpha offset by (+1.5, +1.5) px, then the --bum-ink pass. That single
trick does 80% of the “drawn” impression for the price of one extra path fill.
Strokes taper — width scales with 1 - 0.35 * |t - 0.5| * 2 along the path — so
they end like a pen lifting rather than a rectangle stopping.
Fills are never flat. Solid areas use one of four repeating pattern
canvases (crosshatch, scribble, dot-stipple, marker-streak) at multiply blend
over the paper. Pattern choice is per material (§6.2), which means material is
readable in monochrome — the grip surface is visibly a different scribble from
the slick one, and that is an accessibility feature, not just decoration.
DPR. The surface goes through gameSurfaceDpr() from lib/display-scale.ts
(capped at 2). Whatever sizes the drawing buffer is also what the render
transform uses. Never reassign canvas.width inside the rAF loop
(docs/design-language.md §12.1 rule 4).
2.4 The cast¶
Anatomy. A character is a head and two arms. No body, no legs. The head is where the personality is; the arms are drawn as tapered strokes, the same in every skin, because they must read instantly as “that is a grabbing arm” from across the screen.
Head: a circle of radius 26px (design space), squashed and stretched by velocity (§2.7). Face is 2–4 strokes plus whatever the head design adds.
Arms: a 4-segment tapered stroke from shoulder point to hand, root width 7px → 3px at the wrist. Rendered as a smoothed polyline through the physics segment positions (§3.1), so the arm visibly whips.
Hands: a mitten — one thumb, one mass. Open (splayed) when not gripping, closed (fist) when gripping, and a distinct “reaching” pose within 12px of a grabbable. That third pose is the single most important readability affordance in the game and is worth more than any UI indicator.
Sixteen launch heads. Unlocked as described in §11.2. Each is a silhouette first — recognisable as a black shape at 32px — because that is how you find yourself in a four-player scramble.
# |
Head |
Silhouette hook |
Unlock |
|---|---|---|---|
1 |
Biro |
Plain doodle head. Default |
Start |
2 |
Eraser |
Rounded pink block, worn corner |
Start |
3 |
Sharpener |
Grey wedge, one hole |
Start |
4 |
Staple |
Wire staple bent into a face |
Clear World 1 |
5 |
Paper Plane |
Folded triangle |
Clear World 1 with all objectives |
6 |
Teacup |
Handle sticking out |
Clear World 2 |
7 |
Whisk |
Wire dome |
12 Recipes in Sizzle Street |
8 |
Balloon |
Sphere + knot + string |
Clear World 3 |
9 |
Lightbulb |
Bulb + filament face |
20 hidden Poses |
10 |
Helm |
Visor slit |
Clear World 4 |
11 |
Inkpot |
Squat pot, dripping |
30 Parcels found |
12 |
Shuriken |
Four points |
Clear World 5 |
13 |
Snowball |
Lumpy, one carrot |
Clear World 6 |
14 |
Helmet |
Fishbowl + antenna |
Clear World 7 |
15 |
Speaker |
Cone + grille |
Clear World 8 |
16 |
Inkblot |
The studio cat, as a head |
100% campaign |
Seat marks. Independently of the head, every seat wears a doodle mark
inked on the forehead: ● seat 1, ▲ seat 2, ■ seat 3, ✚ seat 4. See §2.8.
2.5 Cosmetics¶
Four slots, all cosmetic, none affecting physics (mass is fixed — a heavier hat would be a competitive item in Showdown, and the source’s economy is cosmetic).
Slot |
Count at launch |
Examples |
|---|---|---|
Head |
16 |
§2.4 |
Hat |
24 |
party hat, chef’s toque, colander, traffic cone, crown (bent), snorkel, halo (drawn with a compass), sticky note stuck to forehead |
Gloves |
12 |
bare mitten, oven mitt, boxing glove, rubber glove, gauntlet, ninja tabi-hand, bubble-wrap |
Ink |
10 |
the four seat pens plus highlighter-yellow, pencil-grey, red-correction, gel-sparkle, invisible-ink (outline only), crayon |
Cosmetics are not coin-purchasable in v1 — they are earned (§11.2). Coins enter later only via the existing shop if the economy team wants them; do not build a second currency.
Persistence: equipped set lives on BumsRushProfile (§10.2) and is echoed
into the room on join so everyone sees the right hat immediately.
2.6 Prop & set-dressing vocabulary¶
Everything in the world is a paper object taped, pinned or drawn onto the sheet. This gives every world a consistent construction language:
Drawn — inked directly onto the paper; static geometry, never moves.
Cut out — a paper shape with a 2px drop shadow and a 1° tilt; anything that moves (platforms, crates, props) so motion reads as a cut-out sliding.
Taped — has visible tape corners; anything that can be broken or detached.
Pinned — has a drawing-pin head; a rotation pivot (levers, wheels, arms).
Torn — jagged edge; a hazard boundary or a hole.
The rule for authors: if it moves, it must be cut out or pinned. A player should be able to tell a moving platform from a wall without moving.
2.7 Juice¶
The game lives or dies on feedback. Per the repo, use lib/motion.ts tokens for
DOM motion; the canvas has its own, listed here so they are consistent between
implementers.
Event |
Response |
|---|---|
Grab connects |
Hand snaps to fist; 1-frame white flash on the gripped surface; short click SFX; 15ms rumble @0.25 |
Grab misses |
Hand splays wide for 120ms; small “fwip” scribble mark at the hand |
Release under load |
3 speed-scribble strokes trailing the head for 200ms |
High-speed travel |
Head stretches along velocity: scale `1 + min(0.35, |
Impact (survivable) |
Head squashes to 0.75 on the impact axis, springs back over 180ms; dust puff of 4 torn-paper bits |
Death |
Head becomes an ink splat (one of 6 hand-drawn blots, randomly rotated) that stays on the sheet for the rest of the attempt; paper crumple SFX; 120ms rumble @0.6 |
Level clear |
The sheet is grabbed at the corner and turned — a page-turn wipe into the results card |
Objective completed |
The objective’s line on the tray gets a highlighter swipe, left to right, 240ms |
Player joins mid-run |
Their head is drawn onto the sheet with a 300ms “sketching in” stroke reveal |
Death splats persist. After a few failed attempts a hard section is visibly covered in the marks of your failures. That is the joke, it is free telemetry for the player, and it is why the assist system in §6.4 can announce itself without feeling like an insult.
2.8 Accessibility of the look¶
Three commitments, all testable:
Seat identity is never colour alone. Each seat has a colour (
--bum-seat-N), a forehead mark (● ▲ ■ ✚), and a name tag that can be pinned on permanently (Settings → “Always show name tags”). The colourblind-safe capability claim rests on the mark, not on the palette.Nothing flashes. Hard ceiling: no more than 3 luminance transitions per second exceeding 10% of the screen area, anywhere, in any world, including Marker Mosh. The rave world pulses via scale and position on the beat, and via a slow (≤1Hz) wash across a maximum of 25% of the frame. This is enforced by review, and there is a note in the world’s level data (
palette.flashSafe: true) so an author cannot quietly opt out.Contrast. Ink on paper is ~12:1. World palettes may tint but must keep the ink/paper pair above 7:1; the level loader asserts this at author time (§6.1) so a pretty world cannot ship unreadable.
§3 Core mechanics — the physics of grabbing¶
3.1 Body model¶
Built on matter-js (already a dependency — matter-js@0.20 +
@types/matter-js, currently used only by lib/rmhvibe). Reasons to pick it
over hand-rolling: constraints, sleeping, broadphase and composite bodies are
exactly what this game is, and it is small, deterministic-enough for a
host-authoritative model (§9.1), and has no WebGL requirement.
One character is a Composite:
Part |
Body |
Notes |
|---|---|---|
Head |
circle, r = 26 |
|
Arm segments ×4 per arm |
capsule, len 22, r 4 |
|
Hand |
circle, r = 10 |
|
Shoulder joint |
|
|
Segment joints |
|
|
Total: 4 players × (1 head + 8 segments + 2 hands) = 44 bodies for actors, plus level geometry. Comfortably inside budget (§17).
Collision layers (collisionFilter):
Layer |
Bit |
Collides with |
|---|---|---|
|
1 |
everything |
|
2 |
|
|
4 |
|
|
8 |
|
|
16 |
everything |
|
32 |
|
|
64 |
|
Arms not colliding with heads or other arms is the single most important decision here: four players tangling in one gap is the point, and arm-vs-arm collision turns that into a jittering knot that ejects everyone.
3.2 Aim model¶
Each arm has a target direction — a unit vector from the analog stick (or its keyboard/touch equivalent, §4). Every physics step, each arm segment receives a torque pulling it toward lying along that direction, strongest at the shoulder and weakest at the wrist:
for i in 0..3:
desired = shoulderPos + dir * (segLen * (i + 0.5))
toward = desired - seg[i].position
force = clamp(toward * ARM_REACH_GAIN * segWeight[i], ARM_FORCE_MAX)
applyForce(seg[i], force)
segWeight = [1.0, 0.85, 0.7, 0.55]. When the stick is centred, the arm goes
limp (gain × 0.15) and dangles — which matters, because “limp arms” is how you
read a player who has let go and is falling.
Reach. Fully extended, hand centre sits 118 px from the head centre.
Stretch Ink (§6.2) raises this to 176 px for 20 s by scaling segLen.
3.3 Grab model¶
The grab is the whole game, so it is specified exactly.
Attach. While the grab input for a hand is held:
Query bodies whose
grabbableflag is set withinGRAB_RADIUS(18 px, or 24 px with assist, §4.7) of the hand centre.Prefer, in order: another player’s hand > another player’s head > a
PROP>WORLDgeometry. (Hand-to-hand beats hand-to-head so that a deliberate handshake wins over an accidental headlock.)Create a
Constraintbetween the hand and the target body at the contact point:stiffness 0.95,damping 0.12,length 0.Latch it. The grip persists while the button is held, even if the target moves away — you are holding on, not overlapping.
Detach when the button releases, when the constraint’s tension exceeds
GRIP_BREAK_FORCE, or when the target is destroyed/despawned.
Grip strength is finite and that is the drama. GRIP_BREAK_FORCE = 0.085
(matter force units). A four-player chain hanging from one hand can tear, and
the tearing is telegraphed: at >70% of break force the arm renders with a
stretched, thinning stroke and the rumble ramps 0.1→0.5. Players learn to feel
this. Do not remove it to “fix” chains breaking.
Slick and grip materials scale the break force (§6.2): ice is 0.45×, rubber matting 1.6×, greased pan 0.25×.
3.4 Chains and throwing¶
No special code. A chain is player A's hand → player B's hand constraints
composed transitively, and a throw is momentum transfer through those
constraints followed by release. Two support systems make it feel deliberate:
Release assist. If a player releases within 80 ms of their peak swing speed, the hand’s release impulse is scaled by 1.08. Invisible, and it makes “let go at the top of the arc” reward timing rather than luck.
Chain read-out. When ≥3 characters are linked, each link’s stroke is drawn slightly thicker and a faint tension gradient runs along it. Four players can see they are one rope.
Anchor rule. A grab is only load-bearing if something in the chain is
attached to WORLD or a PROP that is itself anchored. Two players holding
only each other in mid-air fall together — the sim gives this for free, and
several levels are built on players discovering it.
3.5 Death, respawn, checkpoints¶
Death on: leaving level bounds; contact with a
HAZARDbody; impact aboveDEATH_SPEED(26 px/step, ≈ a 3-storey fall) againstWORLD.Respawn at the last checkpoint reached by the party, after a 900 ms splat-and-crumple beat. The source respawns at the level start; we use checkpoints because our sessions are shorter and our audience is drop-in — losing three minutes of four-player progress to one player’s mistake is a churn event on a website in a way it is not on a console. Checkpoints are generous and authored (§6.7): roughly every 25–40 s of intended play.
A dead player never blocks the party. They respawn alone at the checkpoint while everyone else keeps playing. This is the anti-frustration keystone.
Party wipe (all players dead simultaneously) → all respawn at the checkpoint, attempt counter +1, and the failure counter for §6.4 ticks.
Ink splats persist for the attempt (§2.7).
3.6 The tunables table¶
Everything above, in one place, as lib/bums-rush/constants.ts. These are the
values to hand a designer; nothing else in the engine should carry a magic
number.
export const PHYSICS = {
FIXED_DT_MS: 1000 / 60,
MAX_SUBSTEPS: 3, // accumulator clamp — never spiral
GRAVITY_Y: 1.15, // matter units
HEAD_RADIUS: 26,
HEAD_MASS: 1.2,
HEAD_AIR_FRICTION: 0.012,
HEAD_RESTITUTION: 0.15,
ARM_SEGMENTS: 4,
ARM_SEG_LENGTH: 22,
ARM_SEG_RADIUS: 4,
ARM_SEG_MASS: 0.05,
ARM_SEG_WEIGHT: [1.0, 0.85, 0.7, 0.55],
ARM_REACH_GAIN: 0.0016,
ARM_FORCE_MAX: 0.0055,
ARM_LIMP_GAIN: 0.15,
ARM_REACH_PX: 118,
ARM_REACH_PX_STRETCHED: 176,
SHOULDER_OFFSET_X: 18,
HAND_RADIUS: 10,
HAND_MASS: 0.12,
HAND_FRICTION: 0.9,
GRAB_RADIUS: 18,
GRAB_RADIUS_ASSIST: 24,
GRIP_STIFFNESS: 0.95,
GRIP_DAMPING: 0.12,
GRIP_BREAK_FORCE: 0.085,
GRIP_WARN_RATIO: 0.7,
RELEASE_ASSIST_WINDOW_MS: 80,
RELEASE_ASSIST_SCALE: 1.08,
DEATH_SPEED: 26,
RESPAWN_DELAY_MS: 900,
DESIGN_WIDTH: 1920, // design space; the stage fits 16:9
DESIGN_HEIGHT: 1080,
} as const;
Tuning discipline. These numbers are a system, not a list. GRIP_BREAK_FORCE
and HEAD_MASS and player count are one equation: raising head mass makes
four-player chains tear at rest. Any PR that changes a PHYSICS value must
state which of the four feel tests it was validated against:
A single player can swing from one handhold and reach a ledge 300 px away.
Two players can chain to cross a 420 px gap with one anchored.
Four players hanging vertically from one anchored hand do not tear at rest, but tear if the bottom player swings hard.
A dropped player falling 1000 px and landing flat survives; falling 1600 px does not.
These four are the acceptance criteria for T3 (§20) and live as unit tests.
3.7 Fixed timestep, and what determinism we do and don’t need¶
The sim runs on a fixed 1/60 s accumulator, max 3 substeps per frame, with rendering interpolated between the last two sim states. Render never drives the sim; a 144Hz monitor and a 30fps phone simulate identically.
We deliberately do not require cross-machine determinism. matter-js is not
bit-reproducible across engines and CPU architectures, and every lockstep design
that assumes otherwise eventually desyncs in a way nobody can reproduce. The
netcode (§9.1) is host-authoritative precisely so this never becomes a question.
Do not add “deterministic” to any module name here.
§4 Controls & bindings¶
The source’s developers explicitly recommend a controller, and its Steam accessibility block lists Full Controller Support, Xbox and PlayStation controller support, and a Keyboard Only Option. We match all three and add touch.
4.1 Gamepad — the default map¶
One arm per trigger/stick pair. This is the layout the genre has settled on and players arrive already knowing it.
Input |
Xbox |
PlayStation |
Switch |
Action |
|---|---|---|---|---|
Left stick |
LS |
L3 |
L Stick |
Aim left arm (direction = stick vector) |
Right stick |
RS |
R3 |
R Stick |
Aim right arm |
Left trigger |
LT |
L2 |
ZL |
Grab / hold — left hand |
Right trigger |
RT |
R2 |
ZR |
Grab / hold — right hand |
Left bumper |
LB |
L1 |
L |
Grab left (alternate — same action) |
Right bumper |
RB |
R1 |
R |
Grab right (alternate) |
Face down |
A |
✕ |
B |
Emote / holler (hold to charge, §4.8) |
Face right |
B |
○ |
A |
Use held item (§6.2) |
Face left |
X |
□ |
Y |
Drop carried object |
Face up |
Y |
△ |
X |
Toggle name tags |
D-pad |
— |
— |
— |
Emote wheel (4 quick emotes) |
Start / Options / + |
— |
— |
— |
Pause (host: pauses room; guest: personal menu, §9.6) |
Select / Share / − |
— |
— |
— |
Scoreboard / objective tray |
Why triggers and bumpers both grab. Analog triggers are the better feel, but some pads have no analog triggers and some players cannot hold a trigger comfortably for minutes at a time. Both being live by default costs nothing and removes a support class entirely.
Analog trigger nuance. Where the trigger reports an axis, grip strength
ramps with pull: below 0.25 no grip; 0.25–1.0 maps to 60%–100% of
GRIP_BREAK_FORCE. A light grip that slips is a mechanic, not a bug — and it is
off under Assist (§4.7).
Deadzones. Radial deadzone of 0.22 with a re-normalised outer range, and a 0.92 saturation radius so square-gated sticks can reach the diagonals. Both are exposed in Settings (0.05–0.40) because worn sticks are common.
Detection & labels. navigator.getGamepads() polled once per frame inside
the existing rAF loop (the pattern in components/nightrail/NightrailGame.tsx).
Brand is inferred from Gamepad.id (Vendor: 054c → PlayStation, 057e →
Nintendo, xinput|Xbox → Xbox, else generic ABXY), and every prompt in the
game re-labels accordingly — including tutorial sticky notes. A PlayStation
player must never be told to press “A”. Detection is a hint, not a lock: the
Settings screen has a manual override.
Rumble via gamepad.vibrationActuator.playEffect('dual-rumble', …) where
supported, with a global intensity slider (0–100%, default 60%) and an off
switch. Effects listed in §2.7. Never assume the API exists; feature-detect.
The user-gesture catch. Browsers do not expose gamepads until a button is
pressed. The title screen therefore shows “Press any button” and listens for
gamepadconnected plus polls, and the device-join prompt (§4.6) explains this
in one line rather than appearing broken.
4.2 Keyboard¶
A full keyboard-only path (the source ships one; so do we), plus a two-players- on-one-keyboard split, which is how couch co-op actually happens on a laptop.
Player 1 (default):
Keys |
Action |
|---|---|
|
Aim left arm (8-way, smoothed — see below) |
|
Aim right arm |
|
Grab left |
|
Grab right |
|
Emote / holler |
|
Use item · |
|
Pause · |
Player 2 (split keyboard, local only): T F G H / I J K L aim, R / O
grab, Y emote. Deliberately reachable by two people sharing one board.
Digital→analog smoothing. A key press does not snap the aim vector; it drives it toward the 8-way target at 12 rad/s, so keyboard swings have momentum instead of teleporting the arm. Without this, keyboard play is genuinely unplayable (a complaint the original game collected), and with it, keyboard is a real option — just less precise.
Keyboard assist default: ON. Keyboard players get GRAB_RADIUS_ASSIST by
default (toggleable). This is the compensation for 8-way aim, and it is why we
can honestly claim a keyboard-only option.
4.3 Mouse¶
Mouse is a supported supplement, not a control scheme: menus, the wardrobe, the world map and the level editor are fully mouse-driven, and in-game the mouse aims the right arm (with left-click = right grab) so a mouse+keyboard player gets one precise arm and one WASD arm. This is a genuinely good hybrid and should be offered explicitly in the input picker, not discovered.
4.4 Touch¶
Full spec in §12. Summary: two thumb zones, drag to aim, Auto-Grab on by default, with an advanced two-button mode for players who want it.
4.5 Remapping¶
Every action above is remappable. This is the remappable-input capability
claim and must actually ship.
// lib/bums-rush/input/bindings.ts
export type ActionId =
| 'aimLeft' | 'aimRight' // axis pairs
| 'grabLeft' | 'grabRight'
| 'emote' | 'useItem' | 'dropItem' | 'toggleTags'
| 'pause' | 'objectives';
export interface Binding {
/** Which physical source. */
source: 'keyboard' | 'gamepad' | 'touch' | 'mouse';
/** KeyboardEvent.code, gamepad button index, or axis pair id. */
code: string;
/** For axis bindings: which of the pair, and its polarity. */
axis?: { index: number; sign: 1 | -1 };
}
export interface BindingSet {
version: 1;
profileName: string;
bindings: Partial<Record<ActionId, Binding[]>>; // multiple per action = alternates
deadzone: number;
saturation: number;
rumble: number; // 0..1
triggerAnalog: boolean;
assist: AssistSettings; // §4.7
}
Stored per device profile (keyed by
gamepad.idhash for pads, one entry for keyboard, one for touch) onBumsRushProfile.bindings(§10.2), mirrored tolocalStorageso a signed-out player keeps them.The remap UI is a DOM screen (not canvas): a table of actions, each row a
TapeButtonthat enters listen mode. It shows the detected brand’s glyphs.Conflict handling: binding a key already used shows an inline warning and offers to swap. Never silently steal.
“Reset to defaults” per profile, and a hard reset.
Serialisation is versioned (
version: 1) with a migration function, because bindings outlive schema changes and a broken binding set is a player who cannot move.
4.6 Local devices, seats and drop-in¶
A seat is a playable character in a room (max 4). A device is a physical input. One browser client may own several seats (couch co-op).
Any unassigned gamepad pressing any button raises the join prompt: a paper card sliding in — “Player 3, press
to join” . Confirm → a seat is requested from the server, the character is sketched in at the checkpoint (§2.7) and play continues. Never pause the game to add a player.Devices are bound to seats by identity, so unplugging and replugging a pad restores its seat within a 60 s grace window rather than orphaning it.
Leaving: hold the pause button 1.5 s, or a disconnected pad after grace → seat is released, the character walks off as a torn-out piece of paper.
The input picker on the title screen lets a solo player pick keyboard, keyboard+mouse, or pad explicitly rather than guessing.
4.7 Assist settings¶
This is the assist-mode capability claim. All are per-player (so one person in
a party can use them without imposing on anyone), all are non-competitive-safe
(they are disabled in ranked Showdown, §8.4), and all are visible in the
seat’s HUD chip so nobody is secretly playing a different game.
Setting |
Default |
Effect |
|---|---|---|
Grab assist |
on (kbd/touch), off (pad) |
|
Sticky grip |
off |
Grip never breaks from tension (only from release) |
Analog triggers |
on |
Off = binary grip at full strength |
Auto-grab |
on (touch), off |
Hand grips automatically on contact while the input is held |
Slow-mo |
off |
Sim runs at 0.75× for this player’s local practice only (solo/practice modes; never in a shared room) |
Extra checkpoints |
off |
Enables the authored optional checkpoint set (§6.7) |
No fall damage |
off |
|
Aim smoothing |
0.35 |
Low-pass on the aim vector; helps tremor and worn sticks |
One-handed mode |
off |
Both arms driven from one stick: aim controls the active arm, grab-toggle swaps. Slower, fully completable |
Solo Ladder leaderboards record which assists were active and file runs into
assisted or clean boards — visible, never hidden, never a scold.
4.8 The holler¶
The face-down button is an emote — the character opens its mouth and yells, with a paper speech-bubble. Held longer = bigger bubble. It exists because a four-player physics game needs a non-verbal “GRAB ME” and because voice chat is out of scope. Four D-pad quick emotes: “grab me!”, “go!”, “sorry”, “wait”. All localised (§15), all rate-limited (6/5s) with a per-seat mute in the scoreboard for the one player who discovers the sorry button.
§5 Camera & staging¶
Levels are wider and taller than one screen; the camera must handle four players who are frequently trying to be in different places.
Frame all live players, plus a 140 px margin, clamped to level bounds.
Zoom range 0.55×–1.35× of design scale. If the required zoom would go below 0.55×, the camera stops zooming and instead shows edge indicators: each off-screen player is a small arrow at the frame edge in their seat colour and mark, with a distance number. Never let a player be invisible with no cue.
Zoom and pan are critically damped toward the target (spring
ω = 6.0,ζ = 1.0), so nothing overshoots and nobody gets motion sick.Dead players are excluded from framing after 400 ms.
Lookahead: the frame biases 0.18 × mean party velocity in the direction of travel, capped at 180 px.
prefers-reduced-motion: lookahead and zoom-spring are halved, and the screen-shake amplitude is 0.Solo camera is tighter (0.85×–1.2×) because there is nobody to frame with.
Showdown split: race arenas frame all players like co-op; last-one-standing arenas are single-screen by design (§8.2), so no split-screen renderer is needed — this is a deliberate scope saving and it constrains arena authoring.
The playfield uses .app-stage-fit + .app-stage (16:9) per §12.1 rule 2. The
HUD sits outside .app-stage (in the letterbox) except for in-world
indicators, which sit inside.
§6 The level system¶
6.1 The level file format¶
Levels are data, in data/bums-rush/levels/<world>/<id>.json, validated by a
zod schema at load. This is the decision that makes §20’s parallel authoring
possible: eight agents can write eight worlds simultaneously and the only shared
code is the schema.
// lib/bums-rush/levels/schema.ts (abridged — full field list is normative)
export const levelSchema = z.strictObject({
version: z.literal(1),
id: z.string().regex(/^w[1-8]-\d{2}$/), // e.g. "w3-07"
world: z.number().int().min(1).max(8),
index: z.number().int().min(1), // order within the world
name: z.string().min(1), // i18n KEY, not display text
minPlayers: z.number().int().min(1).max(4),
maxPlayers: z.number().int().min(1).max(4).default(4),
parSeconds: z.number().positive(), // for the Clock objective
bounds: z.object({ x: z.number(), y: z.number(), w: z.number(), h: z.number() }),
palette: z.object({
paper: z.string(), ink: z.string(), accent: z.string(),
flashSafe: z.literal(true), // §2.8 — cannot be opted out of
contrastRatio: z.number().min(7), // asserted by the loader
}),
spawn: z.array(pointSchema).min(1).max(4),
goal: z.object({ shape: shapeSchema, requires: z.enum(['any', 'all']) }),
checkpoints: z.array(z.object({
at: pointSchema, optional: z.boolean().default(false), // §4.7 extra checkpoints
})),
geometry: z.array(z.object({
shape: shapeSchema, // rect | poly | circle | chain
material: materialIdSchema, // §6.2
render: z.enum(['drawn', 'cutout', 'taped', 'pinned', 'torn']),
grabbable: z.boolean().default(true),
})),
props: z.array(propSchema), // §6.2 — discriminated union on `kind`
hazards: z.array(hazardSchema), // §6.3
objectives: z.array(objectiveSchema).length(3), // §7 — exactly three
decorations: z.array(decorationSchema), // sticky notes, doodles, coffee rings
assistBeams: z.array(shapeSchema), // §6.4
music: z.string(), // track id
authorNotes: z.string().optional(), // not shipped to client
});
Loader rules (lib/bums-rush/levels/loader.ts):
Parses with the strict schema; an unknown key is an error, exactly as the catalog does — the same class of bug (a typo that silently renders nothing).
Asserts
palette.contrastRatioagainst the actual computed ratio ofink/paperand refuses to load below 7:1.Asserts
spawn.length >= minPlayers, every objective is reachable-by-type, the goal is insidebounds, and no prop overlaps a spawn point.Levels are lazily fetched per world (one JSON bundle per world, ~9 levels, gzip target < 60 KB/world) so the initial load ships World 1 only.
The manifest
data/bums-rush/levels/index.jsonlists worlds, level ids,minPlayersand par times — small enough to ship in the initial bundle so the world map renders before any world is fetched.
6.2 The prop catalog¶
Every prop is a kind in a discriminated union, each with its own params. The
source’s item list (“pop guns, drones, space ships, ski lifts, keys, and
ketchup … and lasers”) maps onto these; the two named power-ups become
Paperweight and Stretch Ink per §0.3.
Materials (affect grip and render pattern — §2.3):
Material |
Grip × |
Friction |
Pattern |
Reads as |
|---|---|---|---|---|
|
1.00 |
0.6 |
crosshatch |
default surface |
|
1.60 |
0.95 |
dot-stipple |
grippy matting |
|
0.45 |
0.05 |
thin-scribble |
slick |
|
0.25 |
0.02 |
marker-streak |
greased / ketchup |
|
1.00 |
0.6 |
broken-hatch |
breaks after 1.2 s held |
|
0.00 |
0.4 |
flat wash |
cannot be gripped at all |
Props:
|
Behaviour |
Params |
|---|---|---|
|
Dynamic body, grabbable, carryable |
|
|
Pinned bar on a pivot; free rotation |
|
|
Chain of 8–20 linked segments, all grabbable |
|
|
Kinematic, follows a path, carries whatever grips it |
|
|
Static until gripped, then falls after |
|
|
Pinned; rotating past |
|
|
Fires |
|
|
Opens on |
|
|
Carryable; opens |
|
|
Grab the handle, aim with the other arm, release to launch whoever is in the barrel |
|
|
Directional air volume; applies force, no collision |
|
|
Surface with tangential velocity |
|
|
Chair on a cable path; grabbable; carries riders |
|
|
High restitution surface |
|
|
Radial attraction on |
|
|
Volume where gravity scales to |
|
|
Carried item; |
|
|
Carryable objective token (§7) |
|
|
Hidden gift → cosmetic unlock (§11.2) |
|
|
Dotted silhouette; completing the pose scores the objective (§7) |
|
|
Equippable; |
— |
|
Pickup: freezes the user in place as a solid platform for |
|
|
Pickup: sets an ally’s arm reach to |
— |
|
Summonable; flies to a dead/stranded player and carries them to the checkpoint |
|
|
Sizzle Street: an accumulator that checks a required set of carried ingredients (§6.6 W2) |
|
|
Logic: AND/OR/NOT/delay over signals — lets authors build puzzles without code |
|
signalRelay deserves a note: it exists so that a level author can express
“both levers, within two seconds, opens the gate” as data. Without it, every
puzzle variant becomes a code change and the parallel authoring in §20 collapses.
6.3 Hazards¶
|
Behaviour |
|---|---|
|
Static lethal surface (drawn as torn paper teeth) |
|
Beam between two points; lethal; |
|
Rotating lethal disc on a path |
|
Kinematic body that kills on being pinned against geometry |
|
Volume; lethal after |
|
Out-of-bounds volume (explicit, for levels with holes mid-map) |
|
Non-lethal but authored as a hazard: strong directional gust |
|
Geometry that becomes |
Rule: a hazard must be visually distinct in monochrome. Every lethal thing is drawn with the torn construction language (§2.6) plus a red-pen outline — two channels.
6.4 Assist: beams, the drone, and the Studio Cat¶
Three escalating, entirely optional supports. The source has both the striped assist beams and a cat that turns everyone into drones after repeated failure; we keep both because they are excellent design and solve a real problem for a drop-in web audience.
Assist beams — striped bars authored per level (
assistBeams), always present, always grabbable, placed to make a hard traversal merely awkward. They cost the Clock objective (§7) if used, so skilled play still has a reason to ignore them. No prompt, no shame, just a visible option.Rescue Drone — after a player has been dead or stranded for 20 s, a paper drone can be summoned (their own
useItem) to lift them to the checkpoint. Cooldown 45 s. This is the fix for “one player is stuck behind a wall while three wait”.Inkblot, the Studio Cat — after 6 party wipes on the same checkpoint, a cat’s paw comes in from the top of the sheet and bats everyone; every player becomes a flying drone with simple directional control until the next checkpoint. It is presented as a joke, not a mercy: the cat is bored of watching. Levels completed with Inkblot’s help are marked with a paw print on the world map and do count as cleared, but do not score the Clock objective. Configurable in Settings (
off/6 wipes/3 wipes).
6.6 The eight worlds¶
Each world introduces mechanics, uses them, complicates them, and hands off. Nine co-op levels each = 72. Seven Showdown arenas each = 56 (§8).
# |
World |
Theme |
New mechanics introduced |
Materials in play |
|---|---|---|---|---|
1 |
Doodle Docks |
Harbour, crates, cranes |
Grab, swing, chain, throw, carry; |
paper, rubber |
2 |
Sizzle Street |
A restaurant kitchen |
|
grease, paper |
3 |
Big Tent Bother |
Circus |
|
rubber, paper |
4 |
Castle Clatter |
Medieval keep |
|
paper, crumbly |
5 |
Shuriken School |
Ninja dojo |
|
nogrip, crumbly |
6 |
Chalk Peaks |
Snowy mountain |
|
ice, paper |
7 |
Vacuum Ward |
Space station |
|
nogrip, rubber |
8 |
Marker Mosh |
A rave in a stationery cupboard |
Beat-synced |
all |
Per-world beats. Each world’s nine levels follow the same shape, which is the contract between the eight authoring agents:
Level |
Role |
|---|---|
01 |
Teach. The new mechanic alone, safe, no death possible in the first 20 s. Sticky-note tutorial. |
02 |
Use. The mechanic with a real gap and a real fall. |
03 |
Combine. The new mechanic + one from a previous world. |
04 |
Twist. The mechanic behaves unexpectedly (the conveyor reverses, the lift is broken). |
05 |
Breather. Short, funny, wide, high objective density. Solo-viable. |
06 |
Escalate. Two of the new mechanics interacting. |
07 |
Team gate. |
08 |
Gauntlet. Long, three checkpoints, everything the world has. |
09 |
Set piece. A single memorable spectacle (the crane collapses, the kitchen floods with ketchup, the rocket launches with you on it). |
World details.
W1 · Doodle Docks. Cream paper, blue ruled lines still visible, everything
drawn in biro. Cranes are pinned; crates are cut-outs; the sea is a scribble that
kills. Teaches, in order: aim (01), grab and swing (01), let go (02), grab a
friend (03 — minPlayers still 1, with a rope standing in for the friend when
solo), carry (04), throw a friend (07). The set piece (09) is a cargo crane that
tips as you climb it, turning the level 30° over 40 seconds.
W2 · Sizzle Street. The paper is a grease-spotted recipe card. Conveyors
carry ingredients; hot pans have a 1.2 s grace with an escalating sizzle; ketchup
is grease and coats anything that touches it (grip × 0.25 for 6 s, drawn as a
red smear on the hands). The recipe objectives — “Sashimi Three Ways”, “The Vegan
Sandwich”, “Friend Juice” — are plate props checking a carried set. 09 is a
flood: the level fills with ketchup from the bottom at 40 px/s.
W3 · Big Tent Bother. Bold circus red-and-yellow marker. Pop cannons need two
players (one holds the handle, one is the ammunition) which is the world’s joke
and its lesson. Trapezes are long swing props with low damping so timing
matters. The most poseOutline objectives of any world (the outlines are
“circus poses” — a human pyramid needs three players and is the game’s best
photograph).
W4 · Castle Clatter. Grey pencil on parchment tint. This is the puzzle
world: signalRelay builds two-lever gates, timed portcullises, and a
three-player pressure-plate sequence in 08. The crusher is a portcullis; the
crumbly material is old mortar. 09 is a catapult that fires the whole party
across a moat in one shot — you must be chained to survive it.
W5 · Shuriken School. Ink-wash, high contrast, deliberate negative space.
nogrip lacquered walls mean whole surfaces are off-limits — the first world
where “where can I grab” is the puzzle. Laser gates cycle on a 900 ms period
(safely above the flash floor). Dark rooms are lit by a carried lantern, which
means one player is holding the light and cannot grab with that hand.
W6 · Chalk Peaks. White paper, chalk-blue ink, everything slightly
snow-blurred. ice reduces grip to 0.45× so swings must be shorter and more
frequent. Ski lifts are moving grab points on a fixed path; wind gusts arrive on
a telegraphed 4 s cycle with visible drawn arrows. 09 is an avalanche chase — the
first and only level with a forced scroll, and it is explicitly excluded from the
no-timed-input claim’s spirit by having no input timing requirement, only a
positional one.
W7 · Vacuum Ward. Cold grey graph paper. zeroG volumes remove the down that
every previous world taught; thrusters give a limited number of impulse charges.
Magnets pull heads but not hands, which makes anchoring feel wrong on purpose.
Airlocks are door chains that need one player to stay behind and operate them,
then be retrieved. 09 is a space-ship launch where the level accelerates.
W8 · Marker Mosh. Highlighter neon on black sugar-paper — the one world with
inverted values. Platforms and fans pulse on the beat of the world’s track
(the level data carries bpm, and the pulse is driven from the audio clock, §14,
not from Date.now()). Everything from every world returns. 09 is the finale:
the party must chain across a collapsing dance floor while the whole sheet is
slowly torn away from the right edge.
6.7 Pacing, difficulty and the solo split¶
minPlayersdistribution. Per world: levels 01–06 and 09 areminPlayers: 1(40 of them site-wide, plus 09s where feasible); 07 and 08 areminPlayers: 2. Solo players see co-op-only levels greyed with a “needs 2 — find someone” button that goes straight to matchmaking (§9.7).Checkpoint density. One per 25–40 s of intended play; a level with no checkpoint may not exceed 45 s of intended play.
optional: truecheckpoints are additional ones that only appear under the Extra Checkpoints assist.Par times.
parSeconds= 1.35 × a competent solo run forminPlayers: 1levels, 1.5 × a competent two-player run otherwise. Generous on purpose: the Clock objective should be earned by not dying, not by speedrunning.No level fails on a clock. This is the
no-timed-inputclaim. Par times gate an optional objective; the avalanche in W6-09 is a moving lethal boundary, which is a hazard, not a timer.First-session promise: a brand-new player, solo, on a phone, reaches the end of W1-01 within 90 seconds of the page loading. If they don’t, W1-01 is wrong.
§7 Objectives, collectibles and the camera¶
Each level carries exactly three optional objectives, matching the source’s “two to three”. They are cosmetic-progression drivers only; the level is “cleared” by reaching the goal.
Type |
Description |
Scored when |
|---|---|---|
|
Finish under |
Goal reached, timer under par, no assist beam or Inkblot used |
|
Carry N |
All N present at the goal when it triggers |
|
Find the hidden |
Held for 600 ms |
|
Photograph a specified situation with the equipped |
Photo validated (below) |
|
Deliver a required ingredient set to a |
Plate satisfied |
|
Reach the goal with zero deaths (party-wide) |
Goal reached, death counter 0 |
Each level picks three; clock appears in ~every level, the rest vary by world.
The camera. An equippable item (useItem fires it) that turns “do something
silly” into a scoreable objective. Photo validation is a predicate over the sim
state at the shutter frame, not image analysis:
// e.g. "Get all four players in one shot, all off the ground, at least one upside down"
{ kind: 'snapshot', id: 'w3-04-pyramid',
predicate: { allSeatsInFrame: true, minSeats: 3, allAirborne: true, anyInverted: true } }
Taken photos are rendered as paper Polaroids into the results screen and the
Scrapbook (§11.3), composited from the actual frame at shutter time (a
drawImage of the stage into a small offscreen canvas — cheap, and the paper
frame does the rest). Photos are local-only in v1 (IndexedDB, cap 50) — no
upload, no moderation surface, no user-content descriptor. Sharing a photo to
the feed is §22 and would need the existing upload-security path.
Parcels (hidden gifts). 3–5 parcel props hidden per world (40 total), each
unlocking a cosmetic. Finding one is a small ceremony: the parcel unwraps into a
sticker that slides onto the results card.
Objective attribution in multiplayer is spelled out in §10.5 — the short version is that objectives are scored for everyone present, because the alternative teaches four people to race each other for a collectible in a co-operative game.
§8 Showdown (versus)¶
The source: “Take revenge on your underperforming co-op teammates in versus mode through a series of frantic challenges” — 2–4 players, first to five wins, across a rotating playlist of arenas and three round types.
8.1 Structure¶
2–4 players. Solo, or 2v2 teams (4 players only).
A match is a sequence of rounds. Each round picks an arena and a round type from the playlist; first to 5 wins takes the match.
Arenas are drawn from the 56 Showdown arenas (7 per world), filtered to those supporting the drawn round type and the current player count.
Between rounds: a 6 s results card with a running tally, skippable by all.
Match length target: 8–14 minutes.
8.2 Round types¶
Type |
Rule |
Arena requirement |
|---|---|---|
|
First to the goal wins. Everyone spawns together. |
A traversal arena with a goal |
|
Last one alive wins. A closing lethal boundary (rising ketchup / shrinking sheet) forces an end within 75 s. |
Enclosed, single-screen |
|
A single |
Compact, contested middle |
Three types keep the mode fresh without becoming a second game. survive is
single-screen by design so we never need a split-screen renderer (§5).
8.3 Grabbing your enemies¶
The whole appeal is that co-op mechanics become weapons unchanged. No new verbs: you can grab a rival’s hand or head and you can let go of them over a pit. Two balance rules only:
No infinite hold. Gripping another player has a 3.5 s cap, then the grip auto-releases and that pair cannot re-grip for 1.5 s. Without this, one strong player pins another for a whole round.
Respawn invulnerability: 1.2 s after respawn, a player cannot be gripped.
8.4 Ranked, casual and integrity¶
Casual (default): all assists allowed, everything visible in the HUD.
Ranked: assists disabled for all seats; Elo-lite rating per player stored on
BumsRushProfile.showdownRating(start 1000, K=24, team matches average the team).Because the sim is host-authoritative (§9.1), a determined host could cheat. Therefore: Showdown is explicitly not wager-eligible — do not add
bums-rushtolib/wager/eligible-games.ts— and ranked results are plausibility-checked server-side (§9.8). Ranked is a fun ladder, not a competitive integrity product, and the UI says so in one line.
§9 Multiplayer architecture¶
This is the section to read twice.
9.2 Session model¶
Room (id: 6-char code, or a matchmaking-assigned uuid)
├─ host: clientId (authoritative simulator; migratable)
├─ mode: 'campaign' | 'showdown' | 'solo-ladder'
├─ level / arena state
└─ seats: Seat[0..3]
├─ seatIndex 0..3 → colour + mark (§2.8)
├─ clientId (which browser owns it)
├─ userId | null (signed-out players can play; §10.4)
├─ localIndex (which pad/keyboard on that client — couch co-op)
└─ cosmetics, assists, ready
A client may own multiple seats (couch co-op). A room caps at 4 seats regardless of how they’re distributed, so “2 on the sofa + 2 online” is a first-class configuration and needs no special code — it falls out of seats being independent of clients.
Rooms are in-memory in the hub (like every other socket-server game); the durable artefacts are the results rows in §10. A restart drops rooms, and the client’s reconnect path (§9.6) handles it by offering to re-host.
9.3 Event catalog¶
Prefix br:. Added to SOCKET_RATE_LIMITS in server/socket-server/config.ts,
which is also the allowlist of valid inbound events.
Client → server:
Event |
Payload |
Rate limit |
|---|---|---|
|
|
10 / 60 s |
|
|
20 / 60 s |
|
|
20 / 60 s |
|
|
30 / 60 s |
|
|
20 / 60 s |
|
|
20 / 60 s |
|
|
20 / 60 s |
|
|
20 / 60 s |
|
|
60 / 60 s |
|
|
40 / 60 s |
|
|
30 / 60 s |
|
hot path — see §9.4 |
2400 / 60 s (40/s) |
|
hot path, host only — see §9.4 |
1800 / 60 s (30/s) |
|
|
300 / 60 s |
|
|
30 / 60 s |
|
host-signed level/round result (§9.8) |
60 / 60 s |
|
|
10 / 60 s |
|
|
120 / 60 s |
|
|
20 / 60 s |
Server → client: br:room (full room snapshot), br:roomList, br:seat
(your seat assignment), br:start, br:input (relayed to host), br:snapshot
(relayed to guests), br:event, br:emote, br:hostChanged, br:peerJoined,
br:peerLeft, br:pong, br:error, br:kicked.
What the server actually validates. It is a relay, but not a dumb one:
Event name is in the rate-limit map; payload is size-capped (
br:snapshot≤ 2 KB,br:input≤ 256 B, everything else ≤ 1 KB).br:snapshotis only accepted from the current host and only broadcast to that host’s room. This one check is what stops a guest from puppeting the room.br:inputis only accepted from a client that owns the seat it claims.Seat claims respect the 4-seat cap and the room’s mode.
Room codes use the existing
config.ROOM_CODE_ALPHABET/generateRoomCode().Names/cosmetic ids are length- and allowlist-checked (cosmetic ids validated against a server-side copy of the cosmetic id list — a client cannot invent
head: '<img src=x>').
9.4 The hot path: input and snapshot formats¶
Both are binary (ArrayBuffer), not JSON. Socket.IO handles binary frames
natively and this cuts bandwidth ~4× versus JSON — which matters on a phone.
Input packet (client → host, 30 Hz, one per owned seat, coalesced into one message per client):
u8 seatCount
per seat:
u8 seatIndex
u16 frame (host frame number this input targets, wrapping)
i8 aimLX, aimLY (-127..127 → -1..1)
i8 aimRX, aimRY
u8 gripL, gripR (0..255 analog trigger)
u8 buttons (bitfield: emote, useItem, drop, tags, …)
= 11 bytes/seat + 1. Each packet repeats the last 3 input frames for loss
tolerance (packets are unreliable in practice even over TCP-ish transports
because of head-of-line delays); the host de-duplicates by frame.
Snapshot (host → guests, 20 Hz):
u16 frame
u8 flags (paused, respawning, cat-active, …)
u8 seatCount
per seat:
u8 seatIndex, state (alive/dead/respawning/drone)
i16 headX, headY (world px, quantised to 1/4 px)
i16 headVX, headVY (quantised)
i16 headAngle (1/64 rad)
i16 handLX, handLY, handRX, handRY
u8 gripL, gripR (0 = none, else target ref)
u16 gripTargetL, gripTargetR (body id, 0 = world)
u8 propCount
per dirty prop:
u16 propId
i16 x, y, angle
Arm segment positions are not transmitted. Guests re-derive them locally by running the same arm solver against the authoritative head and hand positions — a 4-segment IK-ish relaxation. Saves ~50% of the packet and is visually indistinguishable, because the arm is a smoothed curve through two known endpoints anyway.
Bandwidth budget: 4 seats × 23 B + ~20 dirty props × 7 B ≈ 240 B/snapshot
→ 4.8 KB/s down per guest at 20 Hz, 0.35 KB/s up. Well inside a mobile
connection, and inside MAX_HTTP_BUFFER_SIZE.
Delta encoding: props are only sent when their transform changed by more than 1/4 px since the last acked snapshot. Every 20th snapshot is a keyframe (all props) so a joiner or a lossy client resynchronises within 1 s.
9.5 Latency handling — what the player feels¶
Three techniques, in order of importance:
Instant local arm rendering. Your own arms render at your local aim vector immediately, every frame, regardless of network state. The head position comes from the authoritative stream, but the arm is the thing you are steering, so input feels instant. When the authoritative arm state arrives and disagrees, the local render blends to it over 80 ms (
easeOutCubic). Guests never see a snapping arm.Lag-compensated grab. This is the one that decides whether online play is good. The host keeps a 250 ms ring buffer of prop transforms and player hand positions (5 snapshots at 20 Hz). When a guest’s input packet arrives with
frame = Fand a rising grip edge, the host resolves the grab query against the world as it was at F (clamped to 250 ms of rewind), then re-projects the resulting attachment point onto the target’s current transform. You grab what you saw. Bounded rewind means a laggy player cannot grab something that no longer exists — beyond the clamp, the grab resolves against the current world and may miss.Interpolation buffer. Guests render remote state 100 ms behind the newest snapshot (two snapshots), extended adaptively up to 200 ms if measured jitter demands it, and contracted back slowly. Below the buffer, extrapolate with velocity for at most 120 ms, then freeze and show the reconnect chrome.
No rollback, no prediction of the local body. Deliberate: coupled-constraint
rollback with matter-js is not achievable at a quality worth the complexity,
and (1)+(2) get us most of the feel for a fraction of the risk. If playtesting
says otherwise, the fallback is more generous lag compensation, not rollback.
Honest degradation. Measured RTT is shown as a small paper “postmark” stamp per seat (green/amber/red, plus a number in Settings). Above 120 ms to the host, that seat’s grab assist auto-enables and a one-line note says why. Above 300 ms sustained for 10 s, the room offers a host migration to the best-connected client.
9.6 Host migration, pause and reconnect¶
Host election: on room creation, the creator. On host loss, the server picks the seat-owning client with the lowest median RTT to the server over the last 30 s (tie → lowest
seatIndex).Migration protocol: server sends
br:hostChangedwith the last keyframe it relayed; the new host loads that keyframe into a fresh sim, freezes for 300 ms (“re-inking” wipe), then resumes. Play resumes from the last checkpoint if the keyframe is older than 2 s, because resuming mid-air from stale state is worse than a small rewind.Voluntary handoff exists so the host can leave gracefully (
br:hostHandoff).Reconnect grace: 90 s. A dropped client’s seats are held, greyed on the seat bar, and their characters become
paperweight-frozen statues rather than ragdolls (so they can’t be the reason everyone dies). After 90 s the seats are released.Pause semantics: in campaign, the host’s pause pauses the sim for everyone (with an on-screen “PAUSED BY
” note) — this is a couch game and that is the couch behaviour. A guest’s pause opens their personal menu without stopping the sim. In Showdown, nobody pauses the sim; the menu is always personal. Use
components/shared/ConnectionStatusfor the reconnect banner and peer-wait overlay — do not write a sixth one.
9.7 Getting into a game — matchmaking, codes, invites, party¶
“Seamless” is the requirement, so all five paths exist and all land in the same
Room:
Quick Play — one button on the title screen.
br:quickPlayjoins the oldest open room matching mode + player count, or creates one and waits. While waiting you are already playing the level solo — the joiner is sketched in when they arrive (§2.7). Nobody stares at a lobby.Room code — 6 chars from
config.ROOM_CODE_ALPHABET(no ambiguous glyphs), shown big on the lobby’s paper card.Invite link —
/bums-rush?room=ABC123, handled by the route’s search params. Follows the existing deeplink pattern; the code is a join code and carries no auth, so it’s safe in a URL (unlike a party ticket, below).Party system — register with the platform party contract so an existing RMH party can queue straight in:
// server/socket-server/handlers/bums-rush.ts import { registerPartyGame } from '../party-contract'; registerPartyGame('bums-rush', { maxPartySize: 4, async createRoomForParty(members) { /* create a private room, reserve seats */ }, async seatWithTicket(socket, ticket) { /* verify + seat */ }, });
Tickets go through router state, never the URL (
lib/party/types.tsis explicit about this).Couch — press any button on a second pad (§4.6). No network at all if you never open a room.
9.8 Result validation and anti-cheat¶
The host reports results (br:result); the server persists them (§10). Since the
host is authoritative, the server applies plausibility bounds rather than
recomputation:
Completion time ≥ a per-level
minPlausibleSeconds(authored, = 0.55 × the developer best) and ≤ 2 h.Objective set is a subset of the level’s three, and
clockrequires the reported time underparSeconds.Seats in the result must match seats the server saw in the room.
Ranked Showdown additionally requires: round count consistent with the score, no round shorter than 3 s, and the host not winning > 85% of ranked rounds over a 20-match window (flag for review via the existing admin audit path).
Anything failing bounds is persisted as unranked and logged; it is never silently dropped (a dropped clear is a support ticket).
Leaderboards (§11.5) show clean and assisted boards separately and are
per level and per player count, because a 4-player time and a solo time are
not the same record.
§10 Saves and persistence¶
10.1 What is saved¶
Thing |
Where |
Why |
|---|---|---|
Cosmetics owned + equipped |
|
Identity — must follow the account |
Bindings, assists, audio, camera settings |
|
A rebound controller must survive a device change |
Per-level best time, objectives, clear flags |
|
Progression + leaderboards |
Parcels found, poses found, recipes made |
|
Cheap, queried as a whole |
Showdown record + rating |
|
Ladder |
Photos (Scrapbook) |
IndexedDB, local only |
No upload surface in v1 (§7) |
Mid-level resume state |
not saved |
Levels are ≤ 3 minutes; a resume system would cost more than it returns |
10.2 Prisma models¶
Own tables, not the shared GameSave blob — per lib/game-saves/registry.ts,
the shared table is for games whose save is one opaque JSON document. Ours is
queried by leaderboard, by level, and by achievement progress, so it needs
columns and indexes.
model BumsRushProfile {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Equipped cosmetics (ids validated against the code-side catalog)
head String @default("biro")
hat String?
gloves String @default("mitten")
ink String @default("seat-1")
unlockedCosmetics String[] @default([])
parcelsFound String[] @default([])
posesFound String[] @default([])
recipesMade String[] @default([])
bindings Json? // BindingSet[] — versioned, see §4.5
settings Json? // assists, audio, camera, rumble, tags
levelsCleared Int @default(0)
deaths Int @default(0)
metresSwung Int @default(0)
showdownRating Int @default(1000)
showdownWins Int @default(0)
showdownLosses Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
clears BumsRushLevelClear[]
@@index([showdownRating])
}
model BumsRushLevelClear {
id String @id @default(cuid())
profileId String
profile BumsRushProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
userId String
levelId String // "w3-07"
playerCount Int // 1..4 — a solo record is not a 4p record
bestMs Int
objectives Int @default(0) // bitmask over the level's 3 objectives
assisted Boolean @default(false) // beams / Inkblot / assist settings used
clears Int @default(1)
firstClearAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, levelId, playerCount])
@@index([levelId, playerCount, assisted, bestMs]) // the leaderboard index
}
model BumsRushShowdownMatch {
id String @id @default(cuid())
mode String // 'casual' | 'ranked'
teams Boolean @default(false)
rounds Int
endedAt DateTime @default(now())
flagged Boolean @default(false) // failed a §9.8 bound
players BumsRushShowdownPlayer[]
@@index([endedAt])
}
model BumsRushShowdownPlayer {
id String @id @default(cuid())
matchId String
match BumsRushShowdownMatch @relation(fields: [matchId], references: [id], onDelete: Cascade)
userId String? // null = signed-out guest
seatIndex Int
roundsWon Int @default(0)
won Boolean @default(false)
ratingBefore Int?
ratingAfter Int?
@@index([userId])
}
model BumsRushRun {
id String @id @default(cuid())
levelId String
playerCount Int
durationMs Int
deaths Int
objectives Int
assisted Boolean
catUsed Boolean @default(false)
userIds String[] // everyone credited (§10.5)
createdAt DateTime @default(now())
@@index([levelId, createdAt])
}
Add the back-relations on User (bumsRushProfile BumsRushProfile?) in the same
migration.
10.3 API routes¶
All under app/routes/api/bums-rush/, all wrapped in defineHandler from
@/lib/api/handler.server — that wrapper is the only place the session → rate
limit → zod → try/catch order is written down, so no hand-rolled handlers.
Route |
Methods |
Auth |
Notes |
|---|---|---|---|
|
GET, PUT |
required |
Read/write the whole profile; PUT body is a zod schema mirroring §10.2, 64 KB cap |
|
POST |
required |
One level clear. Idempotent-ish: upserts on |
|
GET |
optional |
|
|
POST |
optional |
Match result; guests recorded with |
rateLimit: 'write' on the mutating ones. The socket handler writes results
through the same server-side functions in lib/bums-rush/progress/save.server.ts
so there is one persistence path, not two that drift.
10.4 Signed-out play, and the merge¶
Signing in must never be a wall in front of a game.
Signed out: everything is playable. Profile + clears live in
localStorageunderbums-rush:profile:v1andbums-rush:clears:v1. Online multiplayer works (the socket hub allows anonymous connections); the guest gets a generated paper name (“Anonymous Biro”).On sign-in:
createCloudSavefromlib/game-saves/cloud-save.tswith a custom transport pointed at/api/bums-rush/profile(this is precisely the case that helper’s docs describe for own-table games).Merge policy (not last-write-wins — that eats progress):
cosmetics/parcels/poses/recipes → union
per-level clears → best time wins, objectives union,
assistedis true only if both arecounters (deaths, metres) → sum, capped at plausible bounds
bindings/settings → the local ones win if the local set was modified more recently than the remote
updatedAt; otherwise remote. Show a one-line toast saying which was kept, with an undo.showdownRating→ remote wins (guests have no rating).
The merge runs once per sign-in, is idempotent, and is unit-tested (T12).
10.5 Attribution in multiplayer¶
The rule: a level clear credits every seat present at the goal, and each
seat’s clear is filed against their own playerCount. Objectives likewise credit
everyone.
Two consequences worth stating so nobody “fixes” them:
A four-player clear does not grant you the solo record. Records are per
playerCount, so nobody is tempted to farm easy solo times by bringing three friends.Relics that one player carried credit the whole party. Bum’s Rush is a co-operative game; making collectibles individual turns four friends into four competitors for a staple, which is the opposite of the design.
Guests (no userId) are simply not persisted; their local storage records the
clear so the experience is identical until they sign in.
§11 Progression, unlocks, economy, achievements¶
11.1 The shape of progression¶
There is no XP bar and no battle pass. Progression is: you clear levels, you find things, you get hats. That is the source’s model and it is the right one for a session that might be twelve minutes long.
Three parallel tracks, all visible on the world map:
Clears — 72 levels. Clearing a world unlocks the next and grants a head.
Objectives — 216 (72 × 3). Thresholds grant hats and gloves.
Parcels — 40 hidden. Each grants one specific cosmetic.
11.2 Unlock table¶
Trigger |
Reward |
|---|---|
First launch |
Heads 1–3, mitten gloves, 4 seat inks |
Clear world N |
The world’s head (§2.4) |
All objectives in a world |
That world’s hat set (3 hats) |
Each parcel |
One specific cosmetic (40 total) |
10 / 25 / 50 / 72 levels cleared |
Hat, gloves, ink, hat |
20 poses / 12 recipes / 30 parcels |
Heads 9, 7, 11 |
100% campaign (72 clears, 216 objectives, 40 parcels) |
Head 16 “Inkblot” + the gold-ink cosmetic |
First Showdown match |
Boxing-glove gloves |
25 Showdown wins |
Crown (bent) |
Nothing is purchasable, nothing is time-limited, nothing expires.
11.3 The Scrapbook¶
The wardrobe screen doubles as a Scrapbook: a paper spread showing your cosmetics as stickers, your camera photos as Polaroids, and a per-world page with its levels, times, objective ticks and parcel silhouettes (unfound ones shown as dotted outlines — visible progress, no location hints). It is the results screen of the whole game and it is where the art direction pays off. DOM, not canvas — so it is accessible, selectable and translatable.
11.4 Achievements (platform)¶
Twelve entries in lib/achievements/catalog.ts, category: 'games',
group: "Bum's Rush". Ids are permanent once shipped (the catalog’s rule), so
they are written out here in full rather than left to the implementer:
id |
Name |
Description |
Tier |
Coins |
Target |
|---|---|---|---|---|---|
|
Get a Grip |
Clear your first level. |
bronze |
10 |
1 |
|
Dockhand |
Clear Doodle Docks. |
bronze |
20 |
1 |
|
Handy |
Clear 25 levels. |
silver |
50 |
25 |
|
All Hands |
Clear every level. |
gold |
150 |
72 |
|
Completionist-ish |
Complete 100 optional objectives. |
silver |
60 |
100 |
|
Unwrapped |
Find all 40 hidden parcels. |
gold |
120 |
40 |
|
Unsplattered |
Clear 10 levels without a single death. |
silver |
60 |
10 |
|
Human Rope |
Form a four-player chain. |
bronze |
20 |
1 |
|
Et Tu |
Win a Showdown match. |
bronze |
20 |
1 |
|
Nemesis |
Win 25 Showdown matches. |
gold |
120 |
25 |
|
Bad Cat |
Get bailed out by Inkblot. |
bronze |
10 |
1 |
|
Splat Artist |
Die 500 times. |
silver |
40 |
500 |
Progress is reported through the existing engagement path (the
engagement.progression pg-boss queue in server/jobs/) — do not write
achievements inline from the socket handler; that is the pattern the jobs tier
exists to replace.
11.5 Leaderboards¶
Per level and per player count and per assist state (§9.8). Rendered on
the level card and in the Scrapbook. Cursor-paginated, cached 60 s. Friends-only
filter reuses the existing follow graph. Solo Ladder (§0.2) is simply the
playerCount = 1 view rolled up across all solo-viable levels, ranked by summed
best times, with unfinished levels excluded from the sum and shown as gaps —
never as a penalty, so a partial ladder is still a rank.
11.6 Coins¶
Coins arrive only through achievements (the table above) and the platform’s existing per-game reward path. No coin sink inside the game in v1: cosmetics are earned, so adding a purchase route would immediately make earning them feel like the slow path. If the economy team wants Bum’s Rush cosmetics in the site shop later, that is a §22 conversation and needs its own design.
§12 Mobile¶
Mobile is a design target, not a port. Roughly half of this site’s sessions are phones; a party game that only works with a gamepad would be a game most visitors cannot play.
12.1 What mobile can and cannot do¶
Capability |
Phone / tablet |
|---|---|
Full campaign, solo |
✅ |
Full campaign, online co-op |
✅ |
Showdown online |
✅ |
Couch co-op on one device |
❌ — two people cannot share one phone’s touchscreen. The device-join prompt is hidden on coarse-pointer-only devices, and the UI never advertises it |
Gamepad on mobile |
✅ — Bluetooth pads work through the same Gamepad API path, and a tablet + pad is a great way to play |
12.2 The touch control model¶
The problem is exact: the game needs two independent analog aims plus two grabs, and a phone has two thumbs. Three schemes, one default.
A. Auto-Grab (default). Each half of the screen belongs to one arm.
Touch down anywhere in a half → a relative virtual stick origins at the touch point. Drag → aim that arm. Release → the arm goes limp and the hand lets go.
The hand grips automatically whenever it contacts a grabbable while the finger is down, and holds until you lift or drag decisively away (> 60 px past the grip point, which reads as “pulling free”).
So: finger down = reach and hold; finger up = let go. One gesture per arm, which is exactly two thumbs.
This is a real simplification of the game’s verb set and it is the right trade: it preserves aim and when to let go — the two decisions the game is actually about — and automates the one (grip timing) that a thumb cannot do well.
B. Two-thumb + grab pads (advanced). Two fixed virtual sticks in the lower corners plus two grab buttons above them, tapped by the same thumbs. Faithful, harder, available in Settings for players who want the full verb set.
C. Tilt-assist (optional add-on). Device tilt contributes ±20% to the aim
vector of whichever arm is currently held, off by default, purely an accessibility
and expressiveness option. Uses the existing gyro permission pattern from
lib/neon-driftway/gyro.ts — never request the permission unprompted.
Touch details that decide whether it feels good:
Relative sticks, never absolute — the thumb should not have to find a spot.
Dead radius 8 px, full-deflection radius 64 px, both settings-adjustable.
Multi-touch:
touch-action: noneon the stage, and pointer capture per identifier so a stray palm cannot steal an arm.The virtual stick is drawn as a drawing-compass arc in the seat’s ink, fading to 30% opacity after 2 s of no movement so it never fights the art.
Haptics via
navigator.vibratewhere available, mapped to the §2.7 events at short durations (10–30 ms). Feature-detect; iOS Safari does not support it and must not warn.
12.3 Layout, viewport and safe areas¶
Everything here is the §12.1 viewport contract from docs/design-language.md,
applied:
Landscape is the intended orientation. In portrait, show a paper card: “Turn your phone sideways” with a drawn rotate arrow — and behind it, still render the level letterboxed so it never feels blocked. Do not lock orientation via the Screen Orientation API (it fails on iOS and breaks accessibility rotation locks).
Level screen uses
.app-viewport+.app-stage-fit/.app-stage(16:9). HUD in.app-hudso it is inset byvar(--safe-*).Title / world map / wardrobe / results use
.app-page— they are documents, and using.app-viewportfor them costs a phone ~110 px permanently (rule 6).Rule 1 in practice: the pause button, the seat bar and the touch sticks all add
var(--safe-top|bottom|left|right). Check in landscape — that is where the notch takes a long edge and where this game is played.No text entry in the level shell, so
--kb-insetonly matters on the lobby (room code) and wardrobe (search) screens; those mountuseKeyboardInsetand size withcalc(100dvh - var(--kb-inset, 0px)).gameSurfaceDpr()caps the buffer at 2× (rule 4). Onperf-litedevices (lib/perf-tier.ts→isLowEndDevice()), cap at 1.5× and drop the boil (§2.3).
12.4 Mobile performance¶
The mobile budget drives §17. Specifically for phones:
Ink layers 0–2 and 5 are baked; only actors and FX redraw. A phone therefore draws ~4 heads, 8 arm curves, 8 hands and < 40 FX particles per frame.
Prop bodies sleep aggressively (
matter-jssleeping enabled,sleepThreshold30 frames).Off-screen props are excluded from the render pass by an AABB check against the camera frustum, and from the sim by
Sleepingwhere the level marks themstaticWhenOffscreen.Audio uses a small sprite sheet (§14) with at most 8 concurrent voices.
Target: 60 fps on a 2022 mid-range Android, degrading to a locked 30 fps (half-rate render, full-rate sim) rather than a variable frame rate, because variable is what feels broken.
§13 Accessibility¶
The five claims in the capabilities entry (§1.2), plus everything that isn’t a claim but is still right.
Area |
Commitment |
|---|---|
Input |
Every action remappable (§4.5). Playable on keyboard alone, pad alone, or touch alone. One-handed mode (§4.7). No action requires simultaneous presses beyond two. No quick-time events anywhere. |
Timing |
No level fails on a timer (§6.7). Par times gate optional objectives only. Slow-mo assist for solo/practice. |
Motion |
|
Flashing |
Structurally impossible to exceed 3 large flashes/second (§2.8); |
Colour |
Seat identity = colour + forehead mark + optional name tag (§2.8). Materials readable by fill pattern (§2.3). Ink/paper ≥ 7:1, loader-asserted. |
Audio |
Every audio cue has a visual twin (grip click → hand pose change; tension warning → stroke thinning + rumble; countdown → drawn numerals). Separate volume sliders for music / SFX / UI. Playable fully muted. |
Text |
All UI text is DOM, not canvas — so it scales with the browser’s font size, is selectable, and is translated (§15). In-world sticky notes are the exception and carry an |
Screen reader |
The game canvas is not screen-reader playable and we do not pretend otherwise; the canvas carries an honest |
Difficulty |
Assist beams, Rescue Drone and Inkblot (§6.4) are always available, never gated, never scolded. |
Two things deliberately not claimed: subtitles (there is no speech to
subtitle — the holler is a wordless yell with a drawn bubble) and screen-reader
playability of the level itself. Per the honesty rule, we do not list what we
have not built.
§14 Audio¶
Design principle: the sound of paper. Every sound is something you could make with stationery, and that constraint keeps a 60-sound game coherent.
Event |
Sound |
|---|---|
Grip connect |
A pen click |
Grip slip |
A pencil skid on paper |
Grip near breaking |
Paper tearing, slowly, pitch rising with tension |
Swing |
Air-through-a-flipped-page whoosh, filtered by speed |
Impact |
A book dropped flat |
Death |
Paper crumpled into a ball, hard |
Respawn |
A pen drawing a quick circle |
Objective |
A highlighter squeak |
Parcel found |
Tape peeled + a small bell |
Level clear |
The page turn |
Inkblot arrives |
A cat’s chirrup + a pencil rolling off a desk |
Implementation.
Web Audio through the site’s existing audio helpers in
lib/audio/; oneAudioContext, unlocked on the first user gesture (the “press any button” screen doubles as the unlock, which is why it exists in §4.1).One sprite sheet per world for SFX (a single decoded buffer, offsets in JSON) — decoding 60 individual files on a phone is a 2-second hitch.
Max 8 concurrent voices, with per-category limits (max 3 grips, 2 impacts) and stealing by oldest. Four players splatting simultaneously must not clip.
Music: one loop per world, plus a Showdown loop and a menu loop. ~9 tracks. Crossfade 800 ms on world change, duck 6 dB under the clear jingle.
Marker Mosh drives visuals from the audio clock (
AudioContext.currentTime), notperformance.now(), so beat-synced platforms cannot drift from the music. The level data carriesbpmandbeatOffsetMs.Everything respects the three sliders and a global mute;
document.hiddensuspends the context (a backgrounded tab making noise is a bug report).
§15 i18n¶
All user-facing strings go through t("key", { defaultValue }), then
pnpm i18n:extract. Two namespaces (§1.2), both added to NAMESPACES in
lib/i18n/config.ts — a JSON file dropped into locales/en/ without that
entry is never loaded, and every locale silently serves the English default.
Three traps specific to this game, all of them ones this repo has been bitten by:
Level and world names are keys, not text. The level schema’s
namefield holdsbums.level.w3-07.name, and the JSON never contains display English. Otherwise 72 level names live outside the i18n pipeline forever.Never put a
{/* … */}JSX comment immediately before at()call —i18next-parserskips that call and the key never lands inlocales/. Put the explanation above the component.Changing a shipped string’s wording means a NEW key.
defaultValueis only used when a key is missing, so editing a default in place changes English and nothing else.
Canvas text. The in-world sticky notes are the only place text is drawn into
the canvas. They pull from the same t() catalogue, and the renderer must
measure and wrap rather than assuming English lengths — German tutorial text is
routinely 40% longer. Sticky notes auto-grow vertically and, past a limit, shrink
the font once and then scroll. RTL (ar, ur): sticky-note text aligns
right and the note’s tape corner mirrors; the world does not mirror (a level is
geography, not a paragraph).
Numerals in the HUD (timer, counters) use Intl.NumberFormat for the active
locale.
§16 Site integration checklist¶
Everything the game touches outside its own directories, in one list, so the final integration ticket has nothing to discover:
lib/catalog/games/bums-rush.ts+ card art atpublic/images/games/Bums-Rush.webplib/game-capabilities.tsentry (parity test will fail without it)lib/i18n/config.ts— two namespaceslib/achievements/catalog.ts— 12 entriesprisma/schema.prisma— 5 models +Userback-relation + migrationserver/socket-server/index.ts— register handlers + disconnectserver/socket-server/config.ts—br:*rate limitsapp/routes/bums-rush.tsx+app/routes/api/bums-rush/*app/globals.css—--bum-*group +@themealiasesdata/bums-rush/levels/**+index.jsonlocales/en/c-bums-rush.json,locales/en/r-bums-rush.json(via extract)pnpm docs:siteregenerated (catalog changed — the freshness gate checks this)docs/README.md— a row for this documentNot
lib/wager/eligible-games.ts(§8.4 — deliberate)Not
lib/game-saves/registry.ts(own tables, §10.2 — deliberate)Not
app/routeTree.gen.ts(generated)Not
components/Providers.tsx(THEME_EXCLUDED_ROUTESderives itself)
§17 Performance budgets¶
The budget is a 2022 mid-range Android at 60 fps, because that is the constraint that makes every other platform easy.
Stage |
Budget (mid phone) |
Notes |
|---|---|---|
Physics step |
≤ 3.5 ms |
4 players (44 bodies) + ≤ 60 prop bodies |
Arm solve (guests) |
≤ 0.6 ms |
8 arms × 4 segments, re-derived from endpoints |
Render — actors + FX |
≤ 5.0 ms |
The only per-frame draw work |
Render — blits |
≤ 1.5 ms |
Baked layers |
Net encode/decode |
≤ 0.4 ms |
Binary packets (§9.4) |
Frame total |
≤ 11 ms |
Leaves headroom inside 16.6 ms |
Hard caps enforced in code:
≤ 4 players, ≤ 120 total physics bodies per level (loader asserts).
≤ 120 FX particles, pooled, never allocated per frame.
≤ 40 death splats retained; oldest fade out.
Zero allocations in the step and render hot paths — vectors are reused scratch objects. This is a review criterion, not a suggestion; GC pauses in a physics game read as input lag.
Initial JS for
/bums-rush≤ 180 KB gzip (the route is lazy-loaded already;matter-jsis ~90 KB gzip and is the bulk of it). World 1 level data ships with it; worlds 2–8 fetch on demand.Time-to-playable on a cold mid-phone load: ≤ 3.5 s on 4G.
Degradation ladder when frame time exceeds budget for 2 s (measured with a rolling median, never a single frame):
Drop the boil (§2.3) → saves ~1.2 ms.
Halve FX particle cap.
Reduce DPR by one step (2 → 1.5 → 1).
Lock to 30 fps render with 60 Hz sim.
Each step is announced nowhere and reversed if the median recovers for 10 s.
§18 File & module map¶
Exhaustive, so tickets in §20 can name files without collisions.
app/
routes/
bums-rush.tsx route (§1.3)
api/bums-rush/
profile.ts clear.ts leaderboard.ts showdown.ts (§10.3)
components/bums-rush/
BumsRushGame.tsx root: shell switch, screen router, engine mount
screens/
TitleScreen.tsx ModeSelect.tsx WorldMap.tsx LevelCard.tsx
Lobby.tsx RoomCodeCard.tsx PauseMenu.tsx ResultsCard.tsx
ShowdownSetup.tsx ShowdownRoundResults.tsx ShowdownMatchResults.tsx
Wardrobe.tsx Scrapbook.tsx SettingsScreen.tsx BindingsScreen.tsx
CreditsScreen.tsx (carries the §0.3 attribution line)
hud/
Hud.tsx SeatBar.tsx ObjectiveTray.tsx EdgeIndicators.tsx
TouchControls.tsx DeviceJoinPrompt.tsx AssistChip.tsx PostmarkPing.tsx
OrientationCard.tsx
paper/
PaperSheet.tsx StickyNote.tsx TapeButton.tsx InkHeading.tsx
DoodleArrow.tsx PolaroidFrame.tsx StickerShelf.tsx
editor/
LevelEditor.tsx Inspector.tsx Palette.tsx (dev-only, §6.5)
lib/bums-rush/
constants.ts PHYSICS + tunables (§3.6)
types.ts shared types (seats, room, snapshot, level)
cosmetics.ts cosmetic id catalog (client + server share this)
engine/
world.ts matter world setup, layers, sleeping
character.ts composite build, arm solver, aim forces (§3.1–3.2)
grab.ts attach/detach, break force, lag-comp hooks (§3.3)
props.ts the prop catalog implementations (§6.2)
hazards.ts (§6.3)
signals.ts signalRelay evaluation
assist.ts beams, drone, Inkblot (§6.4)
step.ts fixed-timestep accumulator (§3.7)
camera.ts framing, zoom, damping (§5)
rng.ts seeded PRNG (boil, arena rotation)
render/
renderer.ts the frame; layer orchestration
paper.ts sheet baking, fibre noise, rules, margin
ink.ts stroke primitives, taper, double-pass
boil.ts the wobble field (§2.3)
patterns.ts material fill patterns
actors.ts heads, arms, hands, cosmetics
fx.ts splats, confetti, scribbles — pooled
worldbake.ts geometry/prop/decoration baking
input/
sources.ts unified InputFrame producer
gamepad.ts polling, brand detection, deadzones, rumble (§4.1)
keyboard.ts 8-way smoothing, split-keyboard (§4.2)
touch.ts Auto-Grab, two-stick, tilt (§12.2)
bindings.ts BindingSet, serialisation, migration (§4.5)
devices.ts device↔seat assignment, join/leave (§4.6)
net/
socket.ts the singleton connection (per repo convention)
protocol.ts event names + zod/binary codecs (§9.3)
snapshot.ts encode/decode, delta, keyframes (§9.4)
host.ts authoritative loop, lag comp, result signing
guest.ts interpolation buffer, local arm blend (§9.5)
migration.ts host election + handoff (§9.6)
lobby.ts room lifecycle client-side
levels/
schema.ts loader.ts manifest.ts validate.ts
progress/
save.ts client save + merge (§10.4)
save.server.ts the single server-side persistence path (§10.3)
objectives.ts evaluation + photo predicates (§7)
unlocks.ts the §11.2 table
leaderboard.ts
audio/
bus.ts sfx.ts music.ts sprites.ts (§14)
__tests__/ (§19)
server/socket-server/handlers/bums-rush.ts (§9.3, §9.7)
data/bums-rush/levels/
index.json
w1/w1-01.json … w1-09.json (× 8 worlds)
showdown/w1-a.json … (56 arenas)
prisma/schema.prisma (§10.2)
§19 Testing plan¶
Colocated under lib/bums-rush/__tests__/ (discovery is a glob — nothing to
register). The suite must stay inside the main pnpm test budget, so no test
runs a real 60-second simulation.
Unit — must exist before the ticket that needs them closes:
Test |
Asserts |
|---|---|
|
The four feel tests in §3.6, as headless sims with fixed input scripts |
|
Target priority order; break force; material scaling; latch persistence |
|
Every shipped level parses; contrast ≥ 7; spawns ≥ minPlayers; goal in bounds; no prop/spawn overlap; the manifest and the world directories agree in both directions; each world’s §6.7 solo/co-op split |
|
A route exists from spawn to goal. Added after |
|
Editor export of an unmodified load is byte-identical (§6.5) |
|
Encode→decode round-trips within quantisation error; delta+keyframe recovery; size bounds |
|
Buffer behaviour under jitter, loss, and reorder; never renders ahead of authority |
|
A grab issued at frame F resolves against F’s world; clamped past 250 ms |
|
Serialise/deserialise; v0→v1 migration; conflict detection |
|
Auto-Grab engage/release thresholds; multi-touch identifier isolation |
|
Each objective type scores exactly when §7 says; photo predicates |
|
§10.4 merge policy, including idempotency on repeated sign-in |
|
The §11.2 table is total and has no unreachable rewards |
|
Client cosmetic ids == server allowlist |
|
Elo maths, team averaging, ranked-assist lockout |
Integration:
handler.test.ts— a fake Socket.IO pair: create → join → claim seats → start → relay → result. Asserts snapshots from a non-host are rejected, seat caps hold, and host migration re-elects deterministically.API route tests for the four endpoints, including the leaderboard’s index usage and the clear endpoint’s better-time-wins upsert.
Platform gates that must stay green (these already exist; the game must not regress them):
lib/__tests__/catalog.test.ts— catalog/capabilities parity + icon namelib/__tests__/design-consistency.test.ts— no raw palette colours, no hardcoded radii, notransition-all, notailwindcss-animate, no hand-rolled tab strips in the DOM screenslib/__tests__/game-viewport-consistency.test.ts— thedvhfallback pair,.app-stageusage, DPR clampinglib/__tests__/game-capabilities.test.ts— the honesty checks
Manual pass before ship (nothing automatable):
All three themes are irrelevant here (theme suppressed) — but check the game against the site’s
lightandhigh-contrastsurrounding chrome on the back-link and error boundary.Landscape phone with a notch: pause button, seat bar and sticks all clear of the cutout.
Two pads + one keyboard on one machine, joining mid-level.
A 200 ms-RTT guest: is the grab still satisfying? This is the single most important subjective check in the project.
Full campaign world 1 on a phone, in German, with reduced motion on.
§20 Work breakdown for subagents¶
Sixteen tickets. Each names its files, its dependencies, and what “done” means. Tickets marked ∥ can run concurrently with their siblings — the whole point of the data-driven level format and the file map above is to make the middle of this graph wide.
Phase 0 — foundation (serial, one agent)¶
T1 · Registration & skeleton.
Files: catalog entry, capabilities entry, route, i18n namespaces, --bum-* in
globals.css, empty components/bums-rush/BumsRushGame.tsx rendering a title
screen, card art placeholder.
Done when: /bums-rush loads, appears on /games, pnpm check:consistency
passes, pnpm docs:site regenerated, catalog parity test green.
Blocks everything.
T2 · Prisma models + API routes.
Files: schema models (§10.2), migration, four API routes (§10.3),
lib/bums-rush/progress/save.server.ts.
Done when: migration applies cleanly, prisma-validate and
prisma-migrate-status pass, route tests green.
Depends on: T1 (id only). ∥ with T3.
Phase 1 — the game feels right (the risky part, do it early)¶
T3 · Physics core. ∥
Files: lib/bums-rush/constants.ts, engine/{world,character,grab,step,rng}.ts,
__tests__/{physics-feel,grab}.test.ts.
Done when: the four feel tests in §3.6 pass, headless, and a dev harness page
lets a human swing one character around a test level with a gamepad.
This ticket is the project’s main technical risk. If the feel tests cannot be
satisfied with the §3.6 numbers, retune the numbers and update §3.6 — do not
proceed to Phase 2 with a character that isn’t fun alone.
T4 · Render pipeline.
Files: render/*, the paper/ink primitives.
Done when: the test level from T3 renders in full notebook style at ≤ 5 ms on a
mid phone, the boil respects prefers-reduced-motion, and the DPR path goes
through gameSurfaceDpr().
Depends on: T3.
T5 · Input layer. ∥ with T4
Files: input/*, hud/{TouchControls,DeviceJoinPrompt}.tsx,
screens/BindingsScreen.tsx, tests.
Done when: the T3 harness is fully playable with (a) a gamepad, (b) keyboard
only, (c) touch Auto-Grab on a real phone; remapping persists across reload;
brand glyphs switch with the detected pad.
Depends on: T3.
Phase 2 — content systems¶
T6 · Level format + loader + editor.
Files: levels/*, components/bums-rush/editor/*, tests.
Done when: a level JSON round-trips through the editor byte-identically, the
loader’s assertions fire on deliberately broken fixtures, and one hand-authored
level (W1-01) plays end to end.
Depends on: T3, T4.
T7 · Props, hazards, signals, assist.
Files: engine/{props,hazards,signals,assist}.ts.
Done when: every kind in §6.2/§6.3 exists with a fixture level exercising it,
signalRelay composes AND/OR/NOT/delay, and Inkblot triggers at the configured
wipe count.
Depends on: T6.
T8 · Camera, HUD, screens.
Files: engine/camera.ts, hud/*, screens/{Title,ModeSelect,WorldMap,LevelCard,Pause,Results}.tsx.
Done when: four players spread across a wide level are all framed or
edge-indicated; the shell switches correctly between .app-page and
.app-viewport; the viewport consistency test is green.
Depends on: T4, T6.
Phase 4 — persistence & progression (∥ with content)¶
T12 · Saves, merge, progression, unlocks.
Files: progress/*, screens/{Wardrobe,Scrapbook}.tsx, achievement entries.
Done when: guest→sign-in merge passes its tests including idempotency; unlocks
fire per §11.2; achievements report through the jobs queue, not inline.
Depends on: T2, T8.
T13 · Accessibility & settings.
Files: screens/SettingsScreen.tsx, engine/assist.ts wiring, reduced-motion
paths, contrast assertions, name tags, audio-cue twins.
Done when: every string in the §1.2 accessibility array is demonstrably
implemented, and the manual pass items 1, 2 and 5 in §19 are signed off. This
ticket owns the honesty of the capabilities entry.
Depends on: T5, T8.
Phase 5 — content (the wide part: up to 8 concurrent agents)¶
T14·W1 … T14·W8 · The eight worlds. ∥∥∥∥∥∥∥∥
Each: 9 co-op levels + 7 Showdown arenas as JSON in data/bums-rush/levels/wN/,
plus the world’s palette, music id, and level-name i18n keys.
Each agent gets: §6.1 (schema), §6.2/§6.3 (props/hazards), §6.6 (their world’s
row and prose), §6.7 (pacing rules), and the editor from T6.
Done when: every level parses, is completable by a human within its parSeconds
× 1.5, has exactly three objectives, respects the level-role table in §6.6, and
minPlayers matches the §6.7 distribution.
Depends on: T6, T7. These eight do not touch shared code — that is the design
that lets them run at once.
T15 · Audio. ∥ with T14
Files: audio/*, sprite sheets, 9 music loops.
Done when: the §14 event table is covered, voice limits hold under a four-player
wipe, Marker Mosh’s pulse is driven by the audio clock, and document.hidden
suspends.
Depends on: T4.
Phase 6 — close out¶
T16 · Showdown, integration, polish.
Files: screens/Showdown*.tsx, net round flow, rating, §16 checklist.
Done when: the §16 checklist is fully ticked, the §0.4 definition of done holds,
pnpm check:consistency --base main is green, and the manual pass in §19 is
complete.
Depends on: everything.
Dependency summary:
T1 ──┬─ T2 ─────────────────────────────┬─ T12 ─┐
└─ T3 ─┬─ T4 ─┬─ T6 ─ T7 ─ T14×8 ──┤ │
│ └─ T8 ───────────────┼─ T13 ─┼─ T16
├─ T5 ─────────────────────┘ │
└─ (T9 ─ T10 ─ T11) ───────────────┘
T15 ─────────┘
§21 Risks & open questions¶
# |
Risk |
Mitigation / owner |
|---|---|---|
1 |
The character doesn’t feel good. Everything else is worthless if swinging is not fun. |
T3 gates the project on four objective feel tests plus a human check before any content is authored. Retune §3.6 rather than proceeding. |
2 |
Online physics feels mushy despite §9.5. |
Ship the lag-comp grab early (T10) and hold the 150 ms manual check as a release gate. Fallback is more generous compensation and auto-assist, not rollback. |
3 |
Host CPU on a phone. A phone hosting four players may not hold 60 Hz. |
Host election prefers desktop clients (detectable via |
4 |
72 levels is a lot of authoring. It is the largest single cost here. |
The data format + editor (T6) exist specifically to make eight agents productive in parallel; the level-role table (§6.6) means an author never faces a blank page. If content slips, ship 4 worlds (36 levels) and treat 5–8 as a content drop — the world map is built to show locked worlds. |
5 |
Touch Auto-Grab may be too simplified for players who know the source. |
Scheme B (§12.2) ships alongside it, and the setting is one tap from the pause menu. |
6 |
|
Budgeted in §17 with hard body caps asserted by the loader; T3 must measure on a real device, not a desktop throttle. |
7 |
Showdown host advantage. Unavoidable given §9.1. |
Ranked is explicitly not a competitive-integrity product (§8.4); not wager-eligible; plausibility bounds + review flags. |
8 |
Scope creep into a level editor for players. |
Explicitly §22, not v1. The dev editor is |
11 |
The swing was partly powered by a bug, and fixing the bug cost reach. |
The pump is fixed and the test is skipped, loudly, with the whole story in its comment — a known regression beats a silent one, and lowering |
10 |
A level can pass every gate and be unplayable. Three of World 4’s nine levels shipped with their goal stranded past an unbridged gap — |
|
9 |
§3.6 feel test 3’s second clause did not survive contact with the engine. The doc says a four-player chain “tears if the bottom player swings hard”. Measured against the shipped tuning, a full-weight pump from the bottom player raises the top grip’s load by 3% (0.068 → 0.070 in matter force units), so no break force can separate rest from swing. The cause is upstream: |
Accepted, not worked around. Grip failure is driven by the SURFACE instead — |
Open questions for the humans, not blockers:
Card art (
Bums-Rush.webp) — needs a real illustration in the notebook style; a programmatic placeholder is fine for T1 but not for launch.Music — 9 loops is a real commission. If that is not available, the fallback is 3 loops (menu / play / Showdown) with per-world instrumentation filters, which is worse but shippable.
Do we want Bum’s Rush cosmetics in the site shop? §11.6 says no for v1; the economy team may disagree, and it is easier to add later than remove.
Localised level names — 72 names × 16 locales goes through the existing AI translate pipeline, but they are jokes, and jokes translate badly. Consider marking level names as
noTranslateand shipping them in English everywhere, which is a legitimate choice for proper nouns.
§22 Post-launch roadmap¶
Not in scope, listed so v1 does not accidentally foreclose them:
World 9 — Manila Manor. The haunted-manor world the source has and we cut for scope: hidden keys, lantern light, rooms that rearrange. The world map, the manifest and the unlock table are all built to take a ninth world without a migration.
Daily Challenge. One level, one modifier (ice everywhere / half gravity / one arm), one shared leaderboard, resets daily. Slots straight into the existing daily-puzzles surface.
Photo sharing to the feed. The Scrapbook’s Polaroids posted as RMHarks. Needs the existing upload-security path and would add the
user-contentdescriptor — hence deliberately out of v1.Player level editor + sharing. The format and the dev editor already exist; what’s missing is moderation, which is the actual cost.
Spectator mode. The snapshot stream is already a complete world state; a read-only viewer is mostly UI, and it makes the game streamable into Live Spaces.
Discord Activity. RMHbox already speaks Discord Activity OAuth; a 2–4 player party game is the ideal second candidate.
Cross-game cosmetics. Hats that unlock from other RMH games, and vice versa — cheap, and it makes the catalogue feel like one place.
Sources¶
Design research for this document, 2026-08-08:
Steam store page for Heave Ho 2 (supplied as saved HTML by the requester; the store domain is egress-blocked from this environment) — official description, feature matrix, accessibility list, tags, platform support.
Repository references: CLAUDE.md, server/CLAUDE.md,
docs/design-language.md §12/§12.1/§13,
docs/page-consistency.md §4,
docs/testing.md, lib/catalog/types.ts,
lib/game-capabilities.ts, lib/game-saves/registry.ts,
server/socket-server/party-contract.ts, server/socket-server/handlers/rochester-offensive.ts.