import React, { useEffect, useMemo, useRef, useState } from "react"; import * as THREE from "three"; // Realtime CYOA Tree Demo // One page demo that simulates audience participation, renders a 3D decision tree, // shows live aspect averages and emits simulated OSC messages to a log panel. // No servers or sockets. Everything is simulated in browser. // ===================================== // Types // ===================================== type Vec5 = [number, number, number, number, number]; type Choice = { id: string; label: string; aspects: Vec5; next?: NodeDef; }; type NodeDef = { id: string; title?: string; prompt?: string; choices: Choice[]; aspectLabels?: string[]; }; type VoteEvent = { userId: string; path: string[]; choiceId: string; aspects: Vec5; wSelf: number; t: number; }; type VizState = { counts: Record; weights: Record; averages: Vec5; axes: { xIndex: number; yIndex: number }; version: number; }; // ===================================== // Config constants // ===================================== const CFG = { dz: 1.0, Lx: 4.0, Ly: 4.0, r0: 0.02, k: 0.06, ghostOpacity: 0.14, ghostFactor: 0.35, emaAlpha: 0.05, deltaThrottleHz: 20, oscHeartbeatHz: 1, overshoot: 0.12, beta: 10, omega: 18, axisTweenMs: 600, }; // ===================================== // Story JSON (depth 4+) // ===================================== const story: NodeDef = { id: "root", title: "The Threshold", prompt: "A door appears in the forest. What do you do?", aspectLabels: ["A1", "A2", "A3", "A4", "A5"], choices: [ { id: "enter", label: "Enter boldly", aspects: [0.8, 0.2, 0.6, 0.3, 0.7], next: { id: "hall", title: "Hall of Echoes", prompt: "Whispers ask for a secret or a truth.", choices: [ { id: "secret", label: "Share a secret", aspects: [0.4, 0.7, 0.5, 0.2, 0.3], next: { id: "mirror", title: "Mirror Gallery", prompt: "Each mirror returns a slightly different you.", choices: [ { id: "embrace", label: "Embrace the reflection", aspects: [0.7, 0.4, 0.8, 0.2, 0.5], next: { id: "apotheosis", choices: [] }, }, { id: "shatter", label: "Shatter an illusion", aspects: [0.3, 0.8, 0.4, 0.6, 0.4], next: { id: "release", choices: [] }, }, ], }, }, { id: "truth", label: "Speak a truth", aspects: [0.6, 0.6, 0.7, 0.4, 0.4], next: { id: "chamber", title: "Chamber of Keys", prompt: "Two keys hum at different pitches.", choices: [ { id: "lowkey", label: "Take the lower key", aspects: [0.2, 0.4, 0.9, 0.5, 0.6], next: { id: "foundations", choices: [] }, }, { id: "highkey", label: "Take the higher key", aspects: [0.9, 0.3, 0.3, 0.7, 0.5], next: { id: "ascension", choices: [] }, }, ], }, }, ], }, }, { id: "observe", label: "Observe first", aspects: [0.3, 0.8, 0.5, 0.6, 0.2], next: { id: "scout", title: "Scout’s Perch", prompt: "You spot two paths…", choices: [ { id: "left", label: "Left toward lanterns", aspects: [0.5, 0.5, 0.2, 0.9, 0.3], next: { id: "lanterns", title: "Lantern Trail", prompt: "Lights pulse with footsteps.", choices: [ { id: "match", label: "Match the rhythm", aspects: [0.4, 0.6, 0.4, 0.8, 0.6], next: { id: "sync", choices: [] }, }, { id: "swerve", label: "Swerve and improvise", aspects: [0.7, 0.2, 0.5, 0.7, 0.7], next: { id: "solo", choices: [] }, }, ], }, }, { id: "right", label: "Right through ruins", aspects: [0.2, 0.9, 0.6, 0.5, 0.5], next: { id: "ruins", title: "Silent Ruins", prompt: "Symbols glow when noticed.", choices: [ { id: "decode", label: "Decode patiently", aspects: [0.3, 0.7, 0.8, 0.3, 0.4], next: { id: "reveal", choices: [] }, }, { id: "rush", label: "Rush forward", aspects: [0.8, 0.4, 0.4, 0.6, 0.6], next: { id: "chase", choices: [] }, }, ], }, }, ], }, }, { id: "wait", label: "Wait and listen", aspects: [0.4, 0.4, 0.3, 0.4, 0.9], next: { id: "circle", title: "Circle of Motes", prompt: "Dust dances in currents.", choices: [ { id: "trace", label: "Trace the spiral", aspects: [0.6, 0.3, 0.6, 0.5, 0.8], next: { id: "spiral", title: "Spiral Walk", prompt: "The spiral tightens toward a hum.", choices: [ { id: "center", label: "Step into center", aspects: [0.5, 0.5, 0.9, 0.4, 0.5], next: { id: "still", choices: [] }, }, { id: "orbit", label: "Keep orbiting", aspects: [0.4, 0.6, 0.4, 0.6, 0.8], next: { id: "flow", choices: [] }, }, ], }, }, { id: "mark", label: "Mark the door", aspects: [0.3, 0.5, 0.5, 0.7, 0.2], next: { id: "map", title: "Traveler’s Map", prompt: "Lines rearrange softly.", choices: [ { id: "followmap", label: "Follow the map", aspects: [0.2, 0.8, 0.7, 0.4, 0.4], next: { id: "charted", choices: [] }, }, { id: "foldmap", label: "Fold a shortcut", aspects: [0.9, 0.2, 0.3, 0.8, 0.3], next: { id: "jump", choices: [] }, }, ], }, }, ], }, }, ], }; // ===================================== // Utility functions and data extraction // ===================================== // Traverse story to build edges and adjacency by parentKey type Edge = { parentKey: string; // "" for root key: string; // e.g. "enter>truth" depth: number; // edge connects depth -> depth+1 aspects: Vec5; }; type StoryIndex = { edges: Edge[]; edgesByDepth: Edge[][]; childrenByParent: Record; aspectLabels: string[]; depthLevels: number; }; function buildIndex(root: NodeDef): StoryIndex { const edges: Edge[] = []; const childrenByParent: Record = {}; const aspectLabels = root.aspectLabels || ["A1", "A2", "A3", "A4", "A5"]; function walk(node: NodeDef, parentKey: string, depth: number) { if (!node.choices) return; const list: string[] = []; for (const choice of node.choices) { const key = parentKey ? `${parentKey}>${choice.id}` : choice.id; list.push(key); edges.push({ parentKey, key, depth, aspects: choice.aspects }); if (choice.next && choice.next.choices) { walk(choice.next, key, depth + 1); } } childrenByParent[parentKey] = list; } walk(root, "", 0); const edgesByDepth: Edge[][] = []; for (const e of edges) { if (!edgesByDepth[e.depth]) edgesByDepth[e.depth] = []; edgesByDepth[e.depth].push(e); } const depthLevels = Math.max(1, edgesByDepth.length); return { edges, edgesByDepth, childrenByParent, aspectLabels, depthLevels }; } // ===================================== // Three helpers // ===================================== function makeCylinderMesh(material: THREE.Material, baseGeo?: THREE.BufferGeometry) { const geo = baseGeo || new THREE.CylinderGeometry(1, 1, 1, 10, 1, true); const mesh = new THREE.Mesh(geo, material); // default scale so radius 1 and height 1 return mesh; } function setSegmentTransform(mesh: THREE.Mesh, a: THREE.Vector3, b: THREE.Vector3, radius: number) { const dir = new THREE.Vector3().subVectors(b, a); const len = dir.length(); if (len < 1e-6) { mesh.visible = false; return; } mesh.visible = true; const mid = new THREE.Vector3().addVectors(a, b).multiplyScalar(0.5); mesh.position.copy(mid); // orientation from Y axis to dir const quat = new THREE.Quaternion(); quat.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.clone().normalize()); mesh.setRotationFromQuaternion(quat); // scale: x z radius, y length mesh.scale.set(Math.max(radius, 1e-4), len, Math.max(radius, 1e-4)); } // ===================================== // Demo component // ===================================== export default function CYOATreeDemo() { const mountRef = useRef(null); const oscLogRef = useRef(null); // Build story index const idx = useMemo(() => buildIndex(story), []); // Live state const [axes, setAxes] = useState({ xIndex: 0, yIndex: 1 }); const [averages, setAverages] = useState([0.5, 0.5, 0.5, 0.5, 0.5]); const [counts, setCounts] = useState>({}); const [weights, setWeights] = useState>({}); const [simRunning, setSimRunning] = useState(false); const [simAgents, setSimAgents] = useState(120); const [simRate, setSimRate] = useState(40); // votes per second const [ghostVisible, setGhostVisible] = useState(true); // Axis tween state const axisTweenRef = useRef<{ start: number; from: { x: number; y: number }[] } | null>(null); // Animation bookkeeping per branch const animStartRef = useRef>({}); // Throttle timers const lastDeltaEmitRef = useRef(0); const lastHeartbeatRef = useRef(0); // OSC log helper const appendOSC = (line: string) => { const el = oscLogRef.current; if (!el) return; const atBottom = Math.abs(el.scrollTop + el.clientHeight - el.scrollHeight) < 10; const div = document.createElement("div"); div.textContent = line; el.appendChild(div); if (atBottom) el.scrollTop = el.scrollHeight; }; // Emit OSC-like messages const emitDelta = (branch: string, count: number, weight: number, avg: Vec5) => { const now = performance.now(); const minGap = 1000 / CFG.deltaThrottleHz; if (now - lastDeltaEmitRef.current < minGap) return; lastDeltaEmitRef.current = now; appendOSC(`/tree/branch ${branch} ${count.toFixed(2)} ${weight.toFixed(2)}`); appendOSC(`/tree/avg ${avg.map((a) => a.toFixed(3)).join(" ")}`); }; const emitAxes = (x: number, y: number) => { appendOSC(`/tree/axes ${x} ${y}`); }; // Heartbeat useEffect(() => { let raf = 0; const loop = () => { const now = performance.now(); if (now - lastHeartbeatRef.current > 1000 / CFG.oscHeartbeatHz) { lastHeartbeatRef.current = now; appendOSC(`/heartbeat ${Math.floor(performance.timeOrigin + now)}`); } raf = requestAnimationFrame(loop); }; raf = requestAnimationFrame(loop); return () => cancelAnimationFrame(raf); }, []); // Simulation of audience useEffect(() => { if (!simRunning) return; type Agent = { id: string; w: number; parentKey: string; path: string[] }; const agents: Agent[] = new Array(simAgents).fill(0).map((_, i) => ({ id: `u${i}`, w: 0.5 + Math.random() * 1.5, parentKey: "", path: [], })); let cancelled = false; let timer: number; const step = () => { if (cancelled) return; const votesPerTick = Math.max(1, Math.floor(simRate / 20)); const ticksPerSec = 20; const delay = 1000 / ticksPerSec; for (let n = 0; n < votesPerTick; n++) { const agent = agents[Math.floor(Math.random() * agents.length)]; const children = idx.childrenByParent[agent.parentKey] || []; if (children.length === 0) { // reset to root agent.parentKey = ""; agent.path = []; continue; } const choiceKey = children[Math.floor(Math.random() * children.length)]; const edge = idx.edges.find((e) => e.key === choiceKey)!; agent.parentKey = choiceKey; agent.path.push(choiceKey); const vote: VoteEvent = { userId: agent.id, path: [...agent.path], choiceId: choiceKey.split(">").pop()!, aspects: edge.aspects, wSelf: agent.w, t: Date.now(), }; handleVote(vote); } timer = window.setTimeout(step, delay) as unknown as number; }; // seed all to first depth so the tree wakes up quickly agents.forEach((a) => { const children = idx.childrenByParent[""] || []; const ck = children[Math.floor(Math.random() * children.length)]; const edge = idx.edges.find((e) => e.key === ck)!; a.parentKey = ck; a.path = [ck]; handleVote({ userId: a.id, path: [...a.path], choiceId: ck.split(">").pop()!, aspects: edge.aspects, wSelf: a.w, t: Date.now(), }); }); step(); return () => { cancelled = true; window.clearTimeout(timer); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [simRunning, simAgents, simRate, idx]); // Handle vote into aggregates const handleVote = (v: VoteEvent) => { setCounts((prev) => ({ ...prev, [v.choiceIdPath ? v.choiceIdPath : v.path[v.path.length - 1]]: (prev[v.path[v.path.length - 1]] || 0) + 1 })); // Since we store by branchKey simplify with local var const branchKey = v.path[v.path.length - 1]; setCounts((prev) => ({ ...prev, [branchKey]: (prev[branchKey] || 0) + 1 })); setWeights((prev) => ({ ...prev, [branchKey]: (prev[branchKey] || 0) + v.wSelf })); // EMA for averages setAverages((prev) => { const next: Vec5 = [0, 0, 0, 0, 0]; for (let i = 0; i < 5; i++) next[i] = CFG.emaAlpha * v.aspects[i] + (1 - CFG.emaAlpha) * prev[i]; // emit osc like delta const c = (counts[branchKey] || 0) + 1; const w = (weights[branchKey] || 0) + v.wSelf; emitDelta(branchKey, c, w, next); return next; }); // start grow animation on this branch animStartRef.current[branchKey] = performance.now(); }; // Axis change tween capture const onAxesChange = (x: number, y: number) => { // capture current projected base positions for all nodes at old axes const now = performance.now(); const from: { x: number; y: number }[] = []; // we record child node ghost base at old axes for each edge index order for (const e of idx.edges) { const baseX = (e.aspects[axes.xIndex] - 0.5) * CFG.Lx * CFG.ghostFactor; const baseY = (e.aspects[axes.yIndex] - 0.5) * CFG.Ly * CFG.ghostFactor; from.push({ x: baseX, y: baseY }); } axisTweenRef.current = { start: now, from }; setAxes({ xIndex: x, yIndex: y }); emitAxes(x, y); }; // Three scene setup const threeRef = useRef<{ scene: THREE.Scene; renderer: THREE.WebGLRenderer; camera: THREE.PerspectiveCamera; cylMat: THREE.Material; ghost: THREE.LineSegments | null; liveGroup: THREE.Group; edgeMeshes: Record; } | null>(null); // Create scene once useEffect(() => { if (!mountRef.current) return; const scene = new THREE.Scene(); scene.background = new THREE.Color(0x0a0b10); const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 100); camera.position.set(7.5, 5.5, 8.5); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(mountRef.current.clientWidth, mountRef.current.clientHeight); mountRef.current.appendChild(renderer.domElement); // Lights (MeshBasicMaterial ignores but keep subtle) const hemi = new THREE.HemisphereLight(0xffffff, 0x222222, 0.6); scene.add(hemi); // Wireframe cube const box = new THREE.BoxGeometry(10, 10, 10); const wf = new THREE.WireframeGeometry(box); const cube = new THREE.LineSegments(wf, new THREE.LineBasicMaterial({ color: 0x2a2e39, transparent: true, opacity: 0.7 })); cube.position.set(0, 0, 5); // center cube so z spans 0..10 scene.add(cube); // Ghost tree lines const ghostGeom = new THREE.BufferGeometry(); const ghostPos: number[] = []; const Lx = CFG.Lx * CFG.ghostFactor; const Ly = CFG.Ly * CFG.ghostFactor; const depthLevels = Math.max(1, idx.depthLevels || 1); const dz = 10 / depthLevels; // Map of node positions for ghost at old axes will be recomputed when axes tweening const ghostNodePos = new Map(); const rootPos = new THREE.Vector3(0, 0, 0); ghostNodePos.set("", rootPos); // Build once for initial axes function ghostPosForKey(e: Edge, xIdx: number, yIdx: number): THREE.Vector3 { const x = (e.aspects[xIdx] - 0.5) * CFG.Lx * CFG.ghostFactor; const y = (e.aspects[yIdx] - 0.5) * CFG.Ly * CFG.ghostFactor; const z = (e.depth + 1) * dz; return new THREE.Vector3(x, y, z); } // Construct line segments for all edges for (const e of idx.edges) { const p0 = ghostNodePos.get(e.parentKey) || new THREE.Vector3(0, 0, e.depth * dz); const p1 = ghostPosForKey(e, 0, 1); // initial axes x 0 y 1 ghostNodePos.set(e.key, p1); ghostPos.push(p0.x, p0.y, p0.z); ghostPos.push(p1.x, p1.y, p1.z); } ghostGeom.setAttribute("position", new THREE.Float32BufferAttribute(ghostPos, 3)); const ghost = new THREE.LineSegments( ghostGeom, new THREE.LineBasicMaterial({ color: 0x9fb7ff, transparent: true, opacity: CFG.ghostOpacity }) ); ghost.position.set(0, 0, 0); scene.add(ghost); // Live branches group const liveGroup = new THREE.Group(); scene.add(liveGroup); const cylMaterial = new THREE.MeshBasicMaterial({ color: 0xf4f6ff, transparent: true, opacity: 0.95 }); const baseGeo = new THREE.CylinderGeometry(1, 1, 1, 12, 1, true); const edgeMeshes: Record = {}; for (const e of idx.edges) { const m = makeCylinderMesh(cylMaterial, baseGeo); liveGroup.add(m); edgeMeshes[e.key] = m; } threeRef.current = { scene, renderer, camera, cylMat: cylMaterial, ghost, liveGroup, edgeMeshes }; // Resize handler const onResize = () => { if (!mountRef.current) return; const w = mountRef.current.clientWidth; const h = mountRef.current.clientHeight; renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix(); }; window.addEventListener("resize", onResize); return () => { window.removeEventListener("resize", onResize); renderer.dispose(); if (ghost) ghost.geometry.dispose(); baseGeo.dispose(); mountRef.current?.removeChild(renderer.domElement); threeRef.current = null; }; }, [idx.edges]); // Render loop to update geometry useEffect(() => { const handle = threeRef.current; if (!handle) return; const { scene, renderer, camera, edgeMeshes } = handle; const depthLevels = Math.max(1, idx.depthLevels || 1); const dz = 10 / depthLevels; // simple auto orbit let t0 = performance.now(); const orbitRadius = 12; const raf = () => { const now = performance.now(); const dt = (now - t0) / 1000; t0 = now; // Camera auto orbit const t = now * 0.00012; // slow const cx = Math.cos(t) * orbitRadius; const cz = Math.sin(t) * orbitRadius; camera.position.set(cx, 6.5, cz); camera.lookAt(0, 0, 5); // Compute sibling sums const sibSum: Record = {}; for (const [parent, children] of Object.entries(idx.childrenByParent)) { let s = 0; for (const ck of children) s += (weights[ck] || 0); sibSum[parent] = Math.max(1e-6, s); } // Axis tween progress const tween = axisTweenRef.current; const tweenT = tween ? Math.min(1, (now - tween.start) / CFG.axisTweenMs) : 1; const ease = tween ? 0.5 - 0.5 * Math.cos(Math.PI * tweenT) : 1; // Map of node positions depth ordered const nodePos = new Map(); nodePos.set("", new THREE.Vector3(0, 0, 0)); for (const depthEdges of idx.edgesByDepth) { for (let i = 0; i < depthEdges.length; i++) { const e = depthEdges[i]; const parent = nodePos.get(e.parentKey) || new THREE.Vector3(0, 0, e.depth * dz); const w = weights[e.key] || 0; const S = sibSum[e.parentKey] || 1; const f = Math.max(0, Math.min(1, S <= 0 ? 0 : w / S)); // base at new axes const baseXNew = (e.aspects[axes.xIndex] - 0.5) * CFG.Lx; const baseYNew = (e.aspects[axes.yIndex] - 0.5) * CFG.Ly; const mixNew = CFG.ghostFactor + (1 - CFG.ghostFactor) * f; const childNew = new THREE.Vector3(baseXNew * mixNew, baseYNew * mixNew, (e.depth + 1) * dz); let child = childNew; if (tween) { // base at old axes from captured from[] in same edge order as idx.edges const idxIndex = idx.edges.indexOf(e); const fromEntry = tween.from[idxIndex]; const mixOld = CFG.ghostFactor + (1 - CFG.ghostFactor) * f; const childOld = new THREE.Vector3(fromEntry.x / CFG.ghostFactor * mixOld, fromEntry.y / CFG.ghostFactor * mixOld, (e.depth + 1) * dz); child = childOld.multiplyScalar(1 - ease).add(childNew.clone().multiplyScalar(ease)); } nodePos.set(e.key, child); // radius and animation const targetR = CFG.r0 + CFG.k * Math.sqrt(Math.max(0, w)); const tStart = animStartRef.current[e.key] || 0; const age = (now - tStart) / 1000; const overshoot = age >= 0 && age <= 0.5 ? CFG.overshoot * Math.exp(-CFG.beta * age) * Math.sin(CFG.omega * age) : 0; const rAnim = targetR * (1 + overshoot); // update mesh const m = edgeMeshes[e.key]; if (m) setSegmentTransform(m, parent, child, rAnim); } } // toggle ghost visibility if (handle.ghost) handle.ghost.visible = ghostVisible; renderer.render(scene, camera); requestAnimationFrame(raf); }; const id = requestAnimationFrame(raf); return () => cancelAnimationFrame(id); }, [axes, weights, idx, ghostVisible]); // Reset aggregates const resetAll = () => { setCounts({}); setWeights({}); setAverages([0.5, 0.5, 0.5, 0.5, 0.5]); animStartRef.current = {}; appendOSC("-- reset --"); }; return (
setGhostVisible(e.target.checked)} />

Crowd averages

Simulation

setSimAgents(parseInt(e.target.value, 10))} /> {simAgents} setSimRate(parseInt(e.target.value, 10))} /> {simRate} per second

OSC out

Simulated log
); } // Simple bars component with EMA smoothing already handled in parent function Bars({ labels, values }: { labels: string[]; values: number[] }) { return (
{labels.map((l, i) => (
{l}
{values[i].toFixed(2)}
))}
); } const btnStyle: React.CSSProperties = { background: "#1a2140", color: "#e6ebff", border: "1px solid #2b3347", borderRadius: 10, padding: "8px 12px", cursor: "pointer", };