product

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/circlesroutes/accountability.ts, services/accountability.ts):

CallUse 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/seenReceiver 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 scaffoldingStoryWalkScene.tsx + its wrapper StoryWalkRoom.tsx + page /seasons/[id]/vr/walk/page.tsx. It already has, working:

  • createXRStore()<Canvas><XR store={store}><XROrigin/>…, Enter-VR button (store.enterVR().catch(()=>{})), loaded dynamic(ssr:false) from the room wrapper (never import @react-three/* at page scope — prerender landmine).
  • Locomotion: teleport (TeleportTarget on 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: a useFrame measures 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 r3f onClick on the wall mesh, which works on controller ray + AVP gaze-pinch — same as the listening-room SceneButton.)
  • 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.

  • Route: /dashboard/circles/vr (a StoryWalkRoom-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

  1. Ring-of-walls (standing, recommended) vs a walkable cubicle maze you clear room by room.
  2. Break gesture: proximity (walk into it), point-and-trigger, or a "punch" (controller velocity). Proximity is the proven, cross-platform default.
  3. Which circle fills the matrix when the player has several (picker vs merge-all).
  4. Wall visuals: name only, or name + avatar + a "streak/last check-in" detail.
  5. 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/circles to 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_TARGET in WallBreakerScene.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/nudge only accepts a nudgee_id you 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 /nudge returns already:true on 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; main is protected (squash-merge via PR). Commit migrations but don't auto-apply them.

First steps checklist for the new session

  1. Read this + WORKOUT_WINDOW_WALL_BREAKER.md + IMMERSIVE_3D_HANDOFF.md. Skim StoryWalkScene.tsx (the gesture + XR scaffolding you're cloning).
  2. Add client wrappers in a lib: fetchMyCircles(), fetchCircleMembers(id), nudgeMember(nudgeeId) (mirror lib/drift.ts helpers; apiRequest attaches auth).
  3. Confirm the v1 shape with the user (ring vs maze; gesture) — the 5 open decisions above.
  4. Build WallBreakerScene by cloning the walk scaffolding: XR store, locomotion, a Wall component (clone Marble: proximity fire → nudgeMember → shatter), a ring layout from the members list, in-scene score + coin <Text>.
  5. WallBreakerRoom wrapper (dynamic(ssr:false)) + route /dashboard/circles/vr + a "🥽 Break walls in VR" link on /dashboard/circles.
  6. Verify: cd apps/frontend && npm run check + a full npm 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 in routes/workout-window.ts, migrations 470_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 / useDriftWallet in lib/drift.ts + services/drift.ts
WALL BREAKER PHASE1 HANDOFF — Docs | HiveJournal