Wall Breaker — Phase 1 (VR cubicle matrix) — build handoff
For: a fresh session picking up Phase 1 of the Workout Window "Wall Breaker" game.
Status: Phase 0 (the nudge → check-in → coin loop) is shipped end-to-end. Phase 1 v1 is now SHIPPED too (2026-07-11) — ring of walls / proximity+point-trigger / name+avatar, at /dashboard/circles/vr (WallBreakerRoom + WallBreakerScene, wrappers in lib/circles.ts). This doc remains the reference for tuning it on-device + the later maze variant. Built blind → geometry/gesture-feel are first-guesses; the tuning constants are grouped at the top of WallBreakerScene.tsx.
Read first: WORKOUT_WINDOW_WALL_BREAKER.md (the concept + phases), GRAPHENE_VR_WALK_MODE.md + IMMERSIVE_3D_HANDOFF.md (the WebXR stack you'll reuse + its landmines).
The game (Phase 1 goal)
You're inside a matrix of cubicle walls. Each wall is a member of your accountability circle who hasn't checked in yet today. Breaking a wall = nudging that person. Break as many as you can each day; the space around you opens up as you clear them. You earn Drift coins later, when someone you nudged actually checks in to their Workout Window. It's the sender's game; the receiver already gets an in-app message + a roll-in marble (Phase 0).
One-line mental model: it's the walk-mode memory-marble gesture (approach → trigger a server action → visual payoff), reskinned — marbles become breakable walls, "find" becomes "nudge", and the walls are circle members instead of Drift treasures.
What Phase 0 already gives you (do NOT rebuild)
The entire data + action + reward loop exists and is live in code (migrations 470/471 — confirm they're applied to prod before expecting real data). The VR client only needs to read the walls and call nudge; the coin payoff is automatic.
The API (all authenticateUser, mounted /api/circles — routes/accountability.ts, services/accountability.ts):
| Call | Use in the game |
|---|---|
GET /api/circles | → { circles: [{ id, name, invite_token, member_count }] }. Pick which circle's members become the walls. |
GET /api/circles/:id/members | → { members: [{ user_id, name, avatar_url, is_me, checked_in_today, nudged_by_me_today }] }. These are the walls. |
POST /api/circles/nudge { nudgee_id } | Break a wall. → { ok, already }. already: true = you already nudged them today (the 1/day cap — the wall is "already broken"). Server enforces shared-circle + the cap; a non-member 400s. |
GET /api/circles/inbox / POST /api/circles/inbox/seen | Receiver side (roll-in marble) — not needed for the sender game. |
The coin payoff is automatic — nothing for the VR client to trigger. When a nudgee checks in, creditNudgersForCheckin (hooked in routes/workout-window.ts right after the check-in insert) credits each nudger +15 Drift coins. So in VR: show the player's live Drift balance (useDriftWallet from lib/drift.ts); it ticks up over the day. There is no client helper for the circles API yet — add thin wrappers in a lib (mirror fetchDriftTreasures/claimVrTreasure).
Which walls to show: members of the chosen circle where is_me === false. A wall is standing if !checked_in_today && !nudged_by_me_today (nudgeable). A wall is already down if checked_in_today (they showed up) or nudged_by_me_today (you already broke it). Daily reset is free — the flags reset each UTC day server-side.
The WebXR stack to reuse (this is 80% of it)
Clone the walk-mode scaffolding — + its wrapper StoryWalkScene.tsx + page StoryWalkRoom.tsx. It already has, working:/seasons/[id]/vr/walk/page.tsx
createXRStore()→<Canvas><XR store={store}><XROrigin/>…, Enter-VR button (store.enterVR().catch(()=>{})), loadeddynamic(ssr:false)from the room wrapper (never import@react-three/*at page scope — prerender landmine).- Locomotion: teleport (
TeleportTargeton the ground → moves the origin group) + smooth stick + snap-turn (useXRControllerLocomotion); desktop pointer-lock + WASD (DesktopWalk).useXR(s => s.session)to gate desktop-only bits. - The break gesture — copy
Marble's proximity fire: auseFramemeasures camera↔object distance and fires once when the player walks within reach (dx*dx+dz*dz < R*R → onFound()). For a wall: approach →onBreak()→POST /api/circles/nudge→ shatter animation. (Or add a point-and-trigger via r3fonClickon the wall mesh, which works on controller ray + AVP gaze-pinch — same as the listening-roomSceneButton.) PlayerTracker(mirrors camera x/z to a ref) and the?author=1-style 2D control pattern if you need an owner/tuning toggle.- 2D overlays are hidden in an immersive session — put the score/coins as an in-scene billboarded
<Text>, not a DOM overlay (a DOM counter is fine only for desktop/pre-VR).
Landmines (already solved in the walk scene — don't regress): fiber v9 (@react-three/fiber ^9.1.4), @types/react pinned 18.3.27, @iwer/devui webpack stub, fiber single-instance alias, JSX shim. Full list: IMMERSIVE_3D_HANDOFF.md.
Built blind: you cannot see renders. Positions/scales/gesture-feel are first-guesses; the user screen-records on a Quest and hands back adjustments. Build in tuning affordances (a ?tune=1-style toggle) where geometry is guessy — copy how the walk scene did it.
Recommended Phase 1 v1 shape (a strong default — confirm with the user)
- Route:
/dashboard/circles/vr(aStoryWalkRoom-style wrapper +WallBreakerScene,dynamic(ssr:false)). Cross-link a "🥽 Break walls in VR" entry from/dashboard/circles. - Layout — a RING of walls around a standing player (v1), not a walkable maze. Place each standing wall on a ring (or a few concentric arcs) facing inward, within reach. You turn + break, no locomotion needed → far more comfortable + simpler than a maze, and reads as "boxed in → clearing space." (Keep locomotion available for a later maze variant.)
- A wall: a cubicle-panel mesh (instanced, low-poly) tagged with the member's name (billboarded
<Text>), avatar if easy, and a subtle "not checked in" tint. Already-down members (checked-in / already-nudged) render as a gap / rubble, not a wall. - Break: approach (proximity) or point-and-trigger → particle burst + the wall falls/dissolves →
POST /api/circles/nudge({ nudgee_id }). On{ already:true }just open it silently. Disable/omit walls that are already down. - Score + reward: in-scene
<Text>"Walls broken today: N" + live Drift coin balance (useDriftWallet, poll every ~30s so coins tick up as nudgees check in during the session). - Circle picker: if the player is in >1 circle, a simple in-scene choice (or
?circle=<id>param) for which circle's members fill the matrix. Default: their first/only circle. - Perf: instance the walls, low-poly, no shadows — target 72fps on Quest (same discipline as the walk-mode forest).
Open decisions for the new session to raise with the user
- Ring-of-walls (standing, recommended) vs a walkable cubicle maze you clear room by room.
- Break gesture: proximity (walk into it), point-and-trigger, or a "punch" (controller velocity). Proximity is the proven, cross-platform default.
- Which circle fills the matrix when the player has several (picker vs merge-all).
- Wall visuals: name only, or name + avatar + a "streak/last check-in" detail.
- Empty state: what the room looks like when everyone's checked in (all walls down = open, celebratory space) or the player has no circle yet (route them to
/dashboard/circlesto make one). RESOLVED (2026-07-11): the ring is topped up to 8 with AI-persona "practice" walls (lib/wallBreakerPersonas.ts) — real members take the inner slots first, personas fill the rest, and no-circle-at-all yields a practice-only room. Persona breaks are cosmetic only (no nudge, no coins — the coin economy stays honest). Tune the roster /RING_TARGETinWallBreakerScene.tsx. Possible follow-up: respawn practice walls after a few seconds so a demo stays endlessly playable.
Constraints / gotchas (save yourself the pain)
- Consent is already enforced server-side —
/nudgeonly accepts anudgee_idyou share a circle with (400 otherwise), and the members endpoint only returns your circle's members. Don't add a client-side people-picker that could nudge outside the circle. - The 1-nudge/member/day cap is DB-enforced (
unique(nudger,nudgee,sent_day));POST /nudgereturnsalready:trueon a repeat — treat that as "wall already broken," not an error. - Coins are async (they land when the other person checks in, maybe hours later) — do NOT show a coin reward on the break itself; that was a bug caught in Phase 0's UI. Show walls-broken now; coins accrue in the wallet over time.
- DreamPro clients are covered (they reuse WW check-ins) — no separate path needed.
- Migrations 470 + 471 must be applied to prod for any of this to have data. If the members endpoint returns only
is_me, the circle is empty (invite people) or the migration isn't applied. - Ship in reviewable slices + one PR each;
mainis protected (squash-merge via PR). Commit migrations but don't auto-apply them.
First steps checklist for the new session
- Read this + WORKOUT_WINDOW_WALL_BREAKER.md + IMMERSIVE_3D_HANDOFF.md. Skim
StoryWalkScene.tsx(the gesture + XR scaffolding you're cloning). - Add client wrappers in a lib:
fetchMyCircles(),fetchCircleMembers(id),nudgeMember(nudgeeId)(mirrorlib/drift.tshelpers;apiRequestattaches auth). - Confirm the v1 shape with the user (ring vs maze; gesture) — the 5 open decisions above.
- Build
WallBreakerSceneby cloning the walk scaffolding: XR store, locomotion, aWallcomponent (cloneMarble: proximity fire →nudgeMember→ shatter), a ring layout from the members list, in-scene score + coin<Text>. WallBreakerRoomwrapper (dynamic(ssr:false)) + route/dashboard/circles/vr+ a "🥽 Break walls in VR" link on/dashboard/circles.- Verify:
cd apps/frontend && npm run check+ a fullnpm run build(new route). Then PR. Hand the user a build to screen-record; iterate on feel.
File map
- Concept + phases: WORKOUT_WINDOW_WALL_BREAKER.md
- Phase 0 backend:
services/accountability.ts,routes/accountability.ts, coin hook inroutes/workout-window.ts, migrations470_accountability_circles.sql+471_accountability_nudge_seen.sql - Phase 0 frontend:
/dashboard/circles(sender),NudgeMarble(receiver) - WebXR stack to clone:
StoryWalkScene.tsx,StoryWalkRoom.tsx,walk page; landmines IMMERSIVE_3D_HANDOFF.md - Coins:
mutateBalance/useDriftWalletinlib/drift.ts+services/drift.ts