product

Graphene VR Story Player — plan

Status: plan / not built (2026-07-10). Reconnaissance done; Phase 1 spec below. Builds on the shipped WebXR stack (IMMERSIVE_3D_HANDOFF.md), the chapter media pipeline, and the already-built Timelines (CYOA) + Drift (findable-passage) systems.

The bet

The Meta Quest catalog is thin, and WebXR runs in the Quest Browser today — an immersive-vr session is a URL you open on the headset, no App Store / App Lab gate. A polished Graphene story player — you're inside a chapter's art, the narration plays, you find the author's hidden Drift passages in the space, and at branch points you see which way the crowd went — is a genre that basically isn't on Quest. We win by not competing on graphics: a 360° image sphere + streamed audio + a few floating text objects is near-zero GPU cost and runs buttery on standalone hardware.

This is the public, standalone player framing — a clean entry surface and story-picker, controller/gaze UX, comfort defaults — not a dashboard-gated dev scene.

Competitive landscape (market analysis, 2026-07-10)

Research pass across the Quest store, SteamVR, PSVR2, WebXR, and FMV precedents. The findings substantiate the bet — the genre we're entering barely exists, the one differentiator we already have is unowned industry-wide, and the URL-first distribution choice is validated. But the market headwinds are real and dictate the framing (VR as halo, not P&L).

The genre is nearly vacant. The Meta Quest store lists only ~5 titles tagged "Choose Your Own Adventure" (vrdb). The mature CYOA form — real branching with consequences (Late Shift, The Complex, Bandersnatch) — lives on flatscreen FMV and has not made the jump to VR. We're defining a category, not fighting for shelf share.

Every premium narrative VR title struggles to monetize as a VR product. This is the load-bearing cautionary tale: Wanderer: The Fragments of Fate ($49.99, well-reviewed, ~$625K lifetime gross / ~$185K net) had its sequel cancelled in 2025 — Mighty Eyes cited a "volatile" VR market and said console sold "solidly" but the standalone-Quest ("mobile") uptake they'd banked on never came (Road to VR). The prestige peak, Wolves in the Walls (Fable Studio — Emmy 2019, Peabody 2022), shipped free with no revenue model (Variety). Down the Rabbit Hole ($19.99, 93% positive) sells but is linear. Takeaway: a business that needs VR-headset TAM is the thing that killed Wanderer 2. Ours must not — VR is one premium surface of a story that also lives on web/audio/print (the Graphene multi-surface thesis). VR is the crown, not the foundation.

Market headwinds (read soberly). Quest is ~53% of standalone VR, but VR/MR headset shipments are down ~42% in 2025 (Quest 9-mo shipments −16% YoY); active VR users fell ~8% to ~21.9M even as usage-per-user rose 30% — fewer people, using it more. Meta's own momentum has pivoted to smart glasses ($2.15B vs. ~$660M Quest hardware in 2025). But 2025 hit Meta's all-time-highest unique Quest users and 100+ apps cleared $1M gross (UploadVR). The installed base is large and monetizing; it's new-hardware growth that stalled. A free, URL-openable room monetized elsewhere is exactly the shape that survives this.

Distribution — the WebXR-URL choice is validated. App-download friction cuts engagement 50–70% vs. browser; WebXR bypasses the store gate entirely (open a URL, load), and Meta's own Immersive Web SDK markets "no app store gatekeeping." Since Connect 2024, WebXR-as-PWA can also be listed on the Horizon Store (Bubblewrap) with in-app payments — so URL-first does not lock us out of the store later; we can graduate (Meta WebXR, UploadVR). The store's curation is why only ~5 CYOA titles exist — experimental narrative struggles to get greenlit. A URL sidesteps the greenlight; we trade store discoverability (we bring our own audience via graphene.fm) for zero gatekeeping. Proof-of-market: heyVR (~300K players/mo, free hosting, dev keeps 100% of net up to $250/mo then 70/30) — "Newgrounds for WebXR" (heyVR docs).

"See what others chose" is unowned — our sharpest wedge. Bandersnatch proved crowd-split reveals are magnetic: Netflix's aggregate stats (60% fed Stefan Frosties; a father's-body choice split 52/48 "identical to the Brexit referendum") became the story about the story (Screen Rant). Yet the aggregate was only shown post-hoc in press — never surfaced at the choice point, in the moment. In VR the mechanic doesn't exist at all (zero FMV-branching titles with a social/crowd-choice feature). Our Timelines crowd-split reveal (~70% built — see below) isn't a me-too feature; it occupies whitespace even Bandersnatch left empty. Do it in the moment, which no one has.

Gaps a solo WebXR creator can exploit: (1) CYOA-in-VR is a near-empty category — define it; (2) in-the-moment crowd-split is unowned; (3) URL distribution defeats the greenlight that keeps experimental narrative off the store; (4) presence-first beats mechanics-first — a solo creator can't out-produce a studio's combat loop but can out-intimate them on narration + branching + voice (our Emberkiln house-voice + Timelines stack); (5) don't bet on headset-TAM growth. Winning frame: "a story that exists everywhere, with a free URL-openable VR room as its most immersive expression and best marketing."

What already exists (reuse, don't rebuild)

WebXR stack — shipped, cloneable

  • Template: StreamXRScene.tsx — the reference scene. createXRStore()<XR store={store}><XROrigin> + a plain Enter-VR button calling store.enterVR(). Desktop fallback via <OrbitControls>.
  • Page wrapper: scene loaded dynamic(() => import(...), { ssr: false })nothing from @react-three/* may be imported at page scope or Next 15 prerender crashes (ReactCurrentBatchConfig). See stream/immersive/page.tsx.
  • Skybox skeleton: SkyDome() (StreamXRScene.tsx:71) is an inverted sphere (sphereGeometry scaled 300×, side: THREE.BackSide). Today it's shader-painted. Swap the ShaderMaterial for a meshBasicMaterial({ side: BackSide, map: <texture> }) and it's a photosphere. This is the one genuinely new rendering piece.
  • Audio + lock-screen controls: useAudioFirstPlayer (lazy URL resolve, binds MediaSession inside the user gesture, never autoplays) + applyChapterMediaSession (Graphene-flavored: album: "${seasonTitle} · Graphene", poster artwork, prev/next). Wire chapter audio straight through these.
  • Landmines (all solved, don't regress): fiber must be v9 (@react-three/fiber ^9.1.4, React-19 reconciler); @types/react pinned 18.3.27; @iwer/devui webpack-aliased to a stub; fiber single-instance alias in next.config.js; JSX type shim types/react-three-fiber.d.ts. Full list in IMMERSIVE_3D_HANDOFF.md.
  • Reality of building here: the agent can't see renders — the immersive stack is tuned by the user screen-recording/screenshotting on-device and handing back adjustments. Expect a screenshot-iterate loop for visual polish, and build in-scene adjust affordances where geometry is guessy.

Chapter media — one public call

  • GET /api/story-seasons/:id/chapters (public, novel-mode) → per chapter { episode_number, title, audio_url, chapter_cover_image_url, paragraphs, audio_duration_seconds, ... }. Handler routes/story-seasons.ts:9598.
  • Graphene stories = story_seasons.is_published_to_graphene = trueGET /api/story-seasons/public (filter mode === 'novel').
  • Image aspect gap: chapter_cover_image_url is 1:1 square. The widest existing art is Scene Studio's 16:9 keyframes (scene_shots.keyframe_url, 1536×1024) — but that route is super-admin-gated. No true 2:1 equirectangular panorama exists → generating real skybox art is its own item (Phase 1.5).
  • Mapper template: streamExtras.ts already maps backend surfaces into scene-consumable shapes (grapheneSeasons(), grapheneLore()). Add a grapheneChapters(seasonId) beside them.

"See what others chose" — ~70% built on Timelines

  • mode='timelines' is a mature CYOA form: a trellis of 2 reader choices → 4 endings, with a working reader/player UI (TimelineClient.tsx) that renders choice cards between chapters and posts to /api/story-seasons/:id/timelines/choice.
  • Choices already persisted per reader (signed-in user_id OR anonymous anon_id): reader_timeline_state.choice_1/choice_2 (migration 208).
  • Crowd split already computed: getTimelineSeasonAnalytics() returns choice_1_distribution: {A, B} — but the endpoint (/timelines/analytics) is super-admin-gated and never shown to readers.
  • Gaps: (1) no reader-facing distribution UI, (2) choice_2 isn't split by parent path, (3) no public choice-distribution endpoint.
  • Viz precedent: cafe_battle_picks (migration 184) already does live "62% picked A" over Supabase Realtime; season_guesses (147) does anonymous poll + reveal. Model on these.
  • Do NOT use Drift decision-points for this — they're authored but not instrumented (branch_traversal_count exists but is never incremented; no reader-selection table). Zero choice data.

"Findable Drift passages" — the discovery layer exists

  • drift_treasures (migration 158): an author plants a verbatim prose excerpt (text_anchor) keyed by (season_id, episode_number), with reward (coins), hint, description. Reader "finds" it by selecting the matching prose → server anchor-match → wallet credit + toast. Per-reader found-tracking in drift_treasure_claims (unique(treasure_id, user_id)).
  • Privacy constraint (load-bearing): the public treasures endpoint strips text_anchor from non-admin readers so the hunt can't be reverse-engineered. Readers get only {id, episode_number, reward, max_claims, hint, description, claimed_by_me}. A VR client cannot pre-fetch passage locations — discovery must stay server-validated (submit the selected/gazed text, server confirms the match). Surfacing Drift in 3D has to respect this: float the hint, reveal the passage only on a validated find.
  • Client lib lib/drift.ts; routes routes/drift.ts; service services/drift.ts.

Phased plan

Phase 1 — The VR Listening Room (single story) ⟵ start here

The demoable core: put on a Quest, open a URL, be inside a Graphene chapter with the narration playing.

  • New public page /vr/[seasonId] (or /play), scene dynamic(ssr:false), cloned from StreamXRScene.
  • Data: grapheneChapters(seasonId)/:id/chapters[{ number, title, audio_url, imageUrl: chapter_cover_image_url }].
  • Scene: photosphere from the chapter image (see skybox note) + a floating chapter title/credits, Enter-VR button, desktop OrbitControls fallback. Narration via useAudioFirstPlayer + applyChapterMediaSession. Prev/next chapter advances the sphere + audio.
  • Skybox approach (Phase-1 pragmatic default): the 1:1 cover won't tile as a true 360. Start by mapping it onto a large curved backdrop / partial dome in front of the viewer (not a full wrap), or a gently-scaled sphere with the seam behind the start orientation — enough to feel immersive and demo the loop now. Generating real equirectangular art is Phase 1.5.
  • Not gated on Drift/branching — this phase is just sphere + audio + chapter nav. Ships value alone.

Phase 1.5 — Wider skybox art

Two steps, cheapest first:

  • 1.5a (no generation, shipped): reuse Scene Studio keyframes. A chapter's Scene Studio keyframes (scene_shots.keyframe_url) are 16:9 — they wrap a sphere far better than the 1:1 cover. A public GET /:id/vr-backdrops (gated to is_published_to_graphene) returns the first keyframe per chapter; the room uses it for the sphere and keeps the square cover for the crisp focal panel. Zero spend, no migration; only improves chapters that already have scene art (else falls back to the cover).
  • 1.5b (later): true equirect. Generate 2:1 equirectangular panoramas per chapter (extend the Scene Studio Flux path — keyframeBuffer() already takes an aspect arg; add a panorama/2:1 option, or a dedicated "immersive backdrop" generator), store on story_episodes (new column), surface in the backdrop payload so the room gets seamless 360 with no client change.

Phase 2 — Drift-in-space (findable passages made concrete)

Surface drift_treasures as findable objects floating in the room. Float the hint on a glyph; "finding" = gaze/point dwell → the passage is revealed and claimed → coins, via a server-validated flow (reuse POST /discover, or a new gated VR-claim endpoint that never ships raw anchors to the client). Respects the anchor-privacy constraint by construction.

Enrichment — in-world entities that fade in on their beat

v1 SHIPPED (2026-07-10) — reuse-only, no generation. The room now floats a few images per chapter around the viewer, each fading in when the narration reaches its beat, via a billboarded arc (FloatingEntity/EntityField in StoryVrScene.tsx). Data comes from a new public endpoint GET /api/story-seasons/:id/vr-entitiesservices/vr-entities.ts getVrEntities(), which assembles per-chapter entities from existing art only: (1) character portraits from the public universe-canon portrait path, matched to the character names that appear in each chapter's prose and timed to the first-mention paragraph (offset = intro_seconds + Σ paragraph_durations, from season_audio_segments; when a season has whole-chapter audio with no per-paragraph durations — durations all 0 — timing falls back to pacing paragraphs proportionally across the chapter's total chapter_audio_duration_seconds, so fade-ins still land on their beat instead of all appearing at once); (2) "moment" keyframes — the chapter's later Scene Studio scene_shots.keyframe_urls (the sphere already uses the first), paced across the narration. Capped at 4/chapter, characters prioritized; gated to published Graphene novels like vr-backdrops. The frontend adds a VrEntity[] to the VrChapter contract (grapheneChapters.ts, best-effort 4th parallel fetch) and drives fade-ins off the <audio> element's timeupdate (elapsed), so it works on desktop and in-headset alike.

v2 SHIPPED (2026-07-10) — the generate half (items/props). The one genuinely-new extraction: standalone items/props (the brass key, the lantern) that today only live embedded inside shots[].visual_prompt. A super-admin-only POST /api/story-seasons/:id/vr-entities/generate (services/vr-entities-gen.ts) runs two missing-only phases: (1) LLM-extract up to 3 items/chapter from the prose (gpt-4o-mini, JSON), (2) synth a cutout image per item (keyframeBuffer Flux, 1:1, isolated object on plain ground), persisting to vr_chapter_entities (migration 468). Cost discipline (real image-gen spend): on-demand only — never a cron, never a read; budget-capped via assertGenBudget('vr_entities.', VR_ENTITIES_DAILY_CAP_USD default $10) re-checked before each paid call; image cost auto-metered into llm_call_log by keyframeBuffer. The public getVrEntities read only READS image_url-ready rows and merges them (kind item, timed to first prose mention) into the round-robin with characters + moments so a chapter shows variety (a character, an item, a moment), capped 5. Items render as smaller square cutouts, lower + closer (at "hand" height), in StoryVrScene.tsx. Trigger per-season (optionally { episodeNumber } for one chapter, { regenImages } to re-render) — a "Generate VR items" button appears under each story card on the /vr hub for super-admins, showing a live count on completion. Original design notes below.

Beyond the single backdrop sphere, populate the room with multiple generated images pulled from the chapter — a character, a scene, an item — each floating as its own object you can look at while the narration reaches that beat. This is not greenfield: Scene Studio already (a) extracts a character bible from chapter prose, (b) generates a keyframe per shot from LLM visual prompts (scene_shots.keyframe_url), and (c) seedSceneCanonFromSeason extracts characters + places from premise + prose into the canon namespace with generated portraits (universe_characters/universe_places, per-entity images). The enrichment is: an LLM pass that picks the N most evocable entities in a chapter (character / setting / item), generate/reuse an image for each (Scene Studio Flux path or the canon portraits), and place them spatially in the room — optionally timed to the narration (an entity fades in as its beat is read, using the per-paragraph audio offsets already in season_audio_segments / the /chapters paragraph_durations). Reuse before generate: prefer existing scene_shots.keyframe_url + canon portraits; only synth what's missing. This is where the room stops being "art on a wall" and becomes "inhabit the chapter."

Phase 3 — Playable branching + crowd choices

For mode='timelines' stories: render choice portals at branch points in VR; on choose, record via the existing /timelines/choice; then reveal the crowd split ("62% went left"). Backend: add a public choice-distribution endpoint (currently super-admin) + choice_2-by-parent aggregation. Parallel non-VR win: the same distribution UI dropped into the flat web TimelineClient.tsx ships "see what others chose" to every web reader — small, independent, shippable before/alongside VR.

Cross-surface applications (the VR renderer as a horizontal capability)

The listening room isn't Graphene-only — the scene is one renderer over a renderer-agnostic data contract (VrStory/VrChapter), so the same capability plugs into other surfaces by feeding it a different story source. Each is its own follow-up, but the architecture is deliberately shaped so they're cheap:

  • EmberKiln — "VR Game Experience" as an output surface. EmberKiln (the production studio, [EMBERKILN_REBRAND] / Production IR) already fans a finished Work out to print / e-book / reel / screenplay / audiobook via the render-kit IR. Add VR room as one more output artifact: a finished story → a /vr/[seasonId] (or exported) experience, listed alongside the other Produce/Publish outputs. Wire into the Produce-mode output list + the render-kit IR renderers. This is the "output surfaces" ask — a new renderer over the same IR, not a new pipeline.
  • Therapy / Odessa — play through your own journal story. Odessa turns a client's journals into a metaphorical story (Flash / Short Story / Novel). A VR play-through lets a client step inside their reframed story — the same room, sourced from an Odessa work instead of a Graphene season (map Odessa's chapters/imagery into VrStory). A therapeutic "inhabit the reframe" surface; gate + consent per the therapy surface's rules. Art-first per Odessa's north star (don't turn the story into coaching).
  • Rehearsal Room — rehearse in VR, hear it in your own voice. Add a VR component to the Rehearsal Room: rehearse the dreaded conversation as an immersive scene, and hear the other side / your own lines played back in your own cloned voice (the shipped voice-clone atom + useAudioFirstPlayer; speech-to-text of the rehearsal → TTS in the trained voice). Ties the VR layer to the Living Voice stack ([LIVING_VOICE_ROADMAP], [AR_GLASSES_LIVING_VOICE]) — the room becomes a place to practice presence, not just consume a story.
  • DreamPro coaching (John Rowley) — VR coaching room. Add VR to the DreamPro coaching experience (the fitness-coaching platform John Rowley rebrands — see the DreamPro Coaching System plan): a coaching/session room that plays a coach's audio/programming inside an immersive space instead of a story chapter — same renderer, sourced from a DreamPro session/program instead of a Graphene season. Gate + brand per the DreamPro/coaching surface. Another proof that the renderer is horizontal: the "story" is just whatever timed audio + imagery the surface feeds it.

Common thread: keep story logic on the server + the data contract platform-neutral; each surface is "point the renderer at a different source," which also keeps the door open for a native visionOS renderer.

Open decisions

  1. Demo story for Phase 1 — which Graphene season has rendered chapter covers + chapter audio to build the first room around? (Needs a mode='novel' story with chapter_cover_image_url + chapter_audio_url populated.)
  2. Skybox now vs. real equirect first — recommend: curved-backdrop with existing covers now (fast demoable loop), generate equirect in 1.5. Confirm.
  3. Public route + entry/vr public player with a story-picker vs. a per-story /seasons/[id]/vr. Recommend a public /vr hub that lists Graphene stories and deep-links per season.
  4. Auth in VR — Timelines choices + Drift claims want an identity. Anonymous anon_id/session_id already works for both systems; keep VR anonymous-friendly (no headset sign-in wall) and reconcile on later web sign-in.

Longer-term: Apple Vision Pro

We likely get AVP for free — no separate app. As of visionOS 2, Safari enables WebXR immersive-vr by default (no feature flag), using a gaze-and-pinch input model (Safari reports a transient-pointer source on pinch). So the same WebXR player built for Quest runs on Vision Pro's Safari. Design the Phase-1 interactions as gaze/point-and-select (a reticle + select), which maps cleanly onto both Quest controllers/hands and AVP gaze-pinch. Caveat: AVP Safari supports immersive-vr but not the AR module (immersive-ar) yet — fine here, a 360 story sphere is VR, not passthrough. AVP's very high display resolution actually makes it an unusually good device for a reading/listening experience (text legibility).

A native visionOS app (SwiftUI + RealityKit / Xcode / App Store) is a longer-term prestige play, not a requirement — justified only for (a) App Store discoverability, (b) passthrough-AR / windows-in-your-room / shared-spatial features WebXR can't do, or (c) a premium high-fidelity reading edition. Hedge baked into the architecture: keep the data/media/Drift/choice layer client-agnostic — the WebXR scene is one renderer over platform-neutral APIs (/:id/chapters, drift, timelines), so a future native visionOS/RealityKit renderer is cheap to add. Don't fork story logic into the client.

Sources: UploadVR — visionOS 2 supports WebXR by default, Apple Developer Forums — WebXR AR module in visionOS 2.x.

GRAPHENE VR STORY PLAYER — Docs | HiveJournal