diff --git a/static/world/controls.js b/static/world/controls.js
new file mode 100644
index 0000000..9282285
--- /dev/null
+++ b/static/world/controls.js
@@ -0,0 +1,50 @@
+/**
+ * Camera + touch controls for the Workshop scene.
+ *
+ * Uses Three.js OrbitControls with constrained range — the visitor
+ * can look around the room but not leave it.
+ */
+
+import { OrbitControls } from "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js";
+
+/**
+ * Set up camera controls.
+ * @param {THREE.PerspectiveCamera} camera
+ * @param {HTMLCanvasElement} domElement
+ * @returns {OrbitControls}
+ */
+export function setupControls(camera, domElement) {
+ const controls = new OrbitControls(camera, domElement);
+
+ // Smooth damping
+ controls.enableDamping = true;
+ controls.dampingFactor = 0.08;
+
+ // Limit zoom range
+ controls.minDistance = 3;
+ controls.maxDistance = 12;
+
+ // Limit vertical angle (don't look below floor or straight up)
+ controls.minPolarAngle = Math.PI * 0.2;
+ controls.maxPolarAngle = Math.PI * 0.6;
+
+ // Limit horizontal rotation range (stay facing the desk area)
+ controls.minAzimuthAngle = -Math.PI * 0.4;
+ controls.maxAzimuthAngle = Math.PI * 0.4;
+
+ // Target: roughly the desk area
+ controls.target.set(0, 1.2, 0);
+
+ // Touch settings
+ controls.touches = {
+ ONE: 0, // ROTATE
+ TWO: 2, // DOLLY
+ };
+
+ // Disable panning (visitor stays in place)
+ controls.enablePan = false;
+
+ controls.update();
+
+ return controls;
+}
diff --git a/static/world/familiar.js b/static/world/familiar.js
new file mode 100644
index 0000000..e862d30
--- /dev/null
+++ b/static/world/familiar.js
@@ -0,0 +1,150 @@
+/**
+ * Pip the Familiar — a small glowing orb that floats around the room.
+ *
+ * Emerald green core with a gold particle trail.
+ * Wanders on a randomized path, occasionally pauses near Timmy.
+ */
+
+import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
+
+const CORE_COLOR = 0x00b450;
+const GLOW_COLOR = 0x00b450;
+const TRAIL_COLOR = 0xdaa520;
+
+/**
+ * Create the familiar and return { group, update }.
+ * Call update(dt) each frame.
+ */
+export function createFamiliar() {
+ const group = new THREE.Group();
+
+ // --- Core orb ---
+ const coreGeo = new THREE.SphereGeometry(0.08, 12, 10);
+ const coreMat = new THREE.MeshStandardMaterial({
+ color: CORE_COLOR,
+ emissive: GLOW_COLOR,
+ emissiveIntensity: 1.5,
+ roughness: 0.2,
+ });
+ const core = new THREE.Mesh(coreGeo, coreMat);
+ group.add(core);
+
+ // --- Glow (larger transparent sphere) ---
+ const glowGeo = new THREE.SphereGeometry(0.15, 10, 8);
+ const glowMat = new THREE.MeshBasicMaterial({
+ color: GLOW_COLOR,
+ transparent: true,
+ opacity: 0.15,
+ });
+ const glow = new THREE.Mesh(glowGeo, glowMat);
+ group.add(glow);
+
+ // --- Point light from Pip ---
+ const light = new THREE.PointLight(CORE_COLOR, 0.4, 4);
+ group.add(light);
+
+ // --- Trail particles (simple small spheres) ---
+ const trailCount = 6;
+ const trails = [];
+ const trailGeo = new THREE.SphereGeometry(0.02, 4, 4);
+ const trailMat = new THREE.MeshBasicMaterial({
+ color: TRAIL_COLOR,
+ transparent: true,
+ opacity: 0.6,
+ });
+ for (let i = 0; i < trailCount; i++) {
+ const t = new THREE.Mesh(trailGeo, trailMat.clone());
+ t.visible = false;
+ group.add(t);
+ trails.push({ mesh: t, age: 0, maxAge: 0.3 + Math.random() * 0.3 });
+ }
+
+ // Starting position
+ group.position.set(1.5, 1.8, -0.5);
+
+ // Wandering state
+ let elapsed = 0;
+ let trailTimer = 0;
+ let trailIndex = 0;
+
+ // Waypoints for random wandering
+ const waypoints = [
+ new THREE.Vector3(1.5, 1.8, -0.5),
+ new THREE.Vector3(-1.0, 2.0, 0.5),
+ new THREE.Vector3(0.0, 1.5, -0.3), // near Timmy
+ new THREE.Vector3(1.2, 2.2, 0.8),
+ new THREE.Vector3(-0.5, 1.3, -0.2), // near desk
+ new THREE.Vector3(0.3, 2.5, 0.3),
+ ];
+ let waypointIndex = 0;
+ let target = waypoints[0].clone();
+ let pauseTimer = 0;
+
+ function pickNextTarget() {
+ waypointIndex = (waypointIndex + 1) % waypoints.length;
+ target.copy(waypoints[waypointIndex]);
+ // Add randomness
+ target.x += (Math.random() - 0.5) * 0.6;
+ target.y += (Math.random() - 0.5) * 0.3;
+ target.z += (Math.random() - 0.5) * 0.6;
+ }
+
+ function update(dt) {
+ elapsed += dt;
+
+ // Move toward target
+ if (pauseTimer > 0) {
+ pauseTimer -= dt;
+ } else {
+ const dir = target.clone().sub(group.position);
+ const dist = dir.length();
+ if (dist < 0.15) {
+ pickNextTarget();
+ // Occasionally pause
+ if (Math.random() < 0.3) {
+ pauseTimer = 1.0 + Math.random() * 2.0;
+ }
+ } else {
+ dir.normalize();
+ const speed = 0.4;
+ group.position.add(dir.multiplyScalar(speed * dt));
+ }
+ }
+
+ // Bob up and down
+ group.position.y += Math.sin(elapsed * 3.0) * 0.002;
+
+ // Pulse glow
+ const pulse = 0.12 + Math.sin(elapsed * 4.0) * 0.05;
+ glowMat.opacity = pulse;
+ coreMat.emissiveIntensity = 1.2 + Math.sin(elapsed * 3.5) * 0.4;
+
+ // Trail particles
+ trailTimer += dt;
+ if (trailTimer > 0.1) {
+ trailTimer = 0;
+ const t = trails[trailIndex];
+ t.mesh.position.copy(group.position);
+ t.mesh.position.x += (Math.random() - 0.5) * 0.1;
+ t.mesh.position.y += (Math.random() - 0.5) * 0.1;
+ t.mesh.visible = true;
+ t.age = 0;
+ // Convert to local space
+ group.worldToLocal(t.mesh.position);
+ trailIndex = (trailIndex + 1) % trailCount;
+ }
+
+ // Age and fade trail particles
+ for (const t of trails) {
+ if (!t.mesh.visible) continue;
+ t.age += dt;
+ if (t.age >= t.maxAge) {
+ t.mesh.visible = false;
+ } else {
+ t.mesh.material.opacity = 0.6 * (1.0 - t.age / t.maxAge);
+ }
+ }
+ }
+
+ return { group, update };
+}
diff --git a/static/world/index.html b/static/world/index.html
new file mode 100644
index 0000000..cd3a99a
--- /dev/null
+++ b/static/world/index.html
@@ -0,0 +1,108 @@
+
+
+
+
+
+ Timmy's Workshop
+
+
+
+
+
+
+
+
+
diff --git a/static/world/scene.js b/static/world/scene.js
new file mode 100644
index 0000000..8dbf318
--- /dev/null
+++ b/static/world/scene.js
@@ -0,0 +1,154 @@
+/**
+ * Workshop scene — room geometry, lighting, materials.
+ *
+ * A dark stone room with a wooden desk, crystal ball, fireplace glow,
+ * and faint emerald ambient light. This is Timmy's Workshop.
+ */
+
+import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
+
+const WALL_COLOR = 0x1a1a2e;
+const FLOOR_COLOR = 0x1a1a1a;
+const DESK_COLOR = 0x3e2723;
+const DESK_TOP_COLOR = 0x4e342e;
+
+/**
+ * Build the room and add it to the given scene.
+ * Returns { crystalBall } for animation.
+ */
+export function buildRoom(scene) {
+ // --- Floor ---
+ const floorGeo = new THREE.PlaneGeometry(8, 8);
+ const floorMat = new THREE.MeshStandardMaterial({
+ color: FLOOR_COLOR,
+ roughness: 0.9,
+ });
+ const floor = new THREE.Mesh(floorGeo, floorMat);
+ floor.rotation.x = -Math.PI / 2;
+ floor.receiveShadow = true;
+ scene.add(floor);
+
+ // --- Back wall ---
+ const wallGeo = new THREE.PlaneGeometry(8, 4);
+ const wallMat = new THREE.MeshStandardMaterial({
+ color: WALL_COLOR,
+ roughness: 0.95,
+ });
+ const backWall = new THREE.Mesh(wallGeo, wallMat);
+ backWall.position.set(0, 2, -4);
+ scene.add(backWall);
+
+ // --- Side walls ---
+ const leftWall = new THREE.Mesh(wallGeo, wallMat);
+ leftWall.position.set(-4, 2, 0);
+ leftWall.rotation.y = Math.PI / 2;
+ scene.add(leftWall);
+
+ const rightWall = new THREE.Mesh(wallGeo, wallMat);
+ rightWall.position.set(4, 2, 0);
+ rightWall.rotation.y = -Math.PI / 2;
+ scene.add(rightWall);
+
+ // --- Desk ---
+ // Table top
+ const topGeo = new THREE.BoxGeometry(1.8, 0.08, 0.9);
+ const topMat = new THREE.MeshStandardMaterial({
+ color: DESK_TOP_COLOR,
+ roughness: 0.6,
+ });
+ const tableTop = new THREE.Mesh(topGeo, topMat);
+ tableTop.position.set(0, 0.85, -0.3);
+ tableTop.castShadow = true;
+ scene.add(tableTop);
+
+ // Legs
+ const legGeo = new THREE.BoxGeometry(0.08, 0.85, 0.08);
+ const legMat = new THREE.MeshStandardMaterial({
+ color: DESK_COLOR,
+ roughness: 0.7,
+ });
+ const offsets = [
+ [-0.8, -0.35],
+ [0.8, -0.35],
+ [-0.8, 0.05],
+ [0.8, 0.05],
+ ];
+ for (const [x, z] of offsets) {
+ const leg = new THREE.Mesh(legGeo, legMat);
+ leg.position.set(x, 0.425, z - 0.3);
+ scene.add(leg);
+ }
+
+ // --- Scrolls / papers on desk (simple flat boxes) ---
+ const paperGeo = new THREE.BoxGeometry(0.3, 0.005, 0.2);
+ const paperMat = new THREE.MeshStandardMaterial({
+ color: 0xd4c5a0,
+ roughness: 0.9,
+ });
+ const paper1 = new THREE.Mesh(paperGeo, paperMat);
+ paper1.position.set(-0.4, 0.895, -0.35);
+ paper1.rotation.y = 0.15;
+ scene.add(paper1);
+
+ const paper2 = new THREE.Mesh(paperGeo, paperMat);
+ paper2.position.set(0.5, 0.895, -0.2);
+ paper2.rotation.y = -0.3;
+ scene.add(paper2);
+
+ // --- Crystal ball ---
+ const ballGeo = new THREE.SphereGeometry(0.12, 16, 14);
+ const ballMat = new THREE.MeshPhysicalMaterial({
+ color: 0x88ccff,
+ roughness: 0.05,
+ metalness: 0.0,
+ transmission: 0.9,
+ thickness: 0.3,
+ transparent: true,
+ opacity: 0.7,
+ });
+ const crystalBall = new THREE.Mesh(ballGeo, ballMat);
+ crystalBall.position.set(0.15, 1.01, -0.3);
+ scene.add(crystalBall);
+
+ // Crystal ball base
+ const baseGeo = new THREE.CylinderGeometry(0.08, 0.1, 0.04, 8);
+ const baseMat = new THREE.MeshStandardMaterial({
+ color: 0x444444,
+ roughness: 0.3,
+ metalness: 0.5,
+ });
+ const base = new THREE.Mesh(baseGeo, baseMat);
+ base.position.set(0.15, 0.9, -0.3);
+ scene.add(base);
+
+ // Crystal ball inner glow
+ const innerLight = new THREE.PointLight(0x88ccff, 0.3, 2);
+ innerLight.position.copy(crystalBall.position);
+ scene.add(innerLight);
+
+ // --- Lighting ---
+
+ // Fireplace glow (warm, off-screen stage left)
+ const fireLight = new THREE.PointLight(0xff6622, 1.2, 8);
+ fireLight.position.set(-3.5, 1.2, -1.0);
+ fireLight.castShadow = true;
+ fireLight.shadow.mapSize.width = 512;
+ fireLight.shadow.mapSize.height = 512;
+ scene.add(fireLight);
+
+ // Secondary warm fill
+ const fillLight = new THREE.PointLight(0xff8844, 0.3, 6);
+ fillLight.position.set(-2.0, 0.5, 1.0);
+ scene.add(fillLight);
+
+ // Emerald ambient
+ const ambient = new THREE.AmbientLight(0x00b450, 0.15);
+ scene.add(ambient);
+
+ // Faint overhead to keep things readable
+ const overhead = new THREE.PointLight(0x887766, 0.2, 8);
+ overhead.position.set(0, 3.5, 0);
+ scene.add(overhead);
+
+ return { crystalBall, fireLight };
+}
diff --git a/static/world/state.js b/static/world/state.js
new file mode 100644
index 0000000..a24e6ad
--- /dev/null
+++ b/static/world/state.js
@@ -0,0 +1,95 @@
+/**
+ * State reader — hardcoded JSON for Phase 2, WebSocket in Phase 3.
+ *
+ * Provides Timmy's current state to the scene. In Phase 2 this is a
+ * static default; the WebSocket path is stubbed for future use.
+ */
+
+const DEFAULTS = {
+ timmyState: {
+ mood: "focused",
+ activity: "Pondering the arcane arts",
+ energy: 0.6,
+ confidence: 0.7,
+ },
+ activeThreads: [],
+ recentEvents: [],
+ concerns: [],
+ visitorPresent: false,
+ updatedAt: new Date().toISOString(),
+ version: 1,
+};
+
+export class StateReader {
+ constructor() {
+ this.state = { ...DEFAULTS };
+ this.listeners = [];
+ this._ws = null;
+ }
+
+ /** Subscribe to state changes. */
+ onChange(fn) {
+ this.listeners.push(fn);
+ }
+
+ /** Notify all listeners. */
+ _notify() {
+ for (const fn of this.listeners) {
+ try {
+ fn(this.state);
+ } catch (e) {
+ console.warn("State listener error:", e);
+ }
+ }
+ }
+
+ /** Try to connect to the world WebSocket for live updates. */
+ connect() {
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
+ const url = `${proto}//${location.host}/api/world/ws`;
+ try {
+ this._ws = new WebSocket(url);
+ this._ws.onopen = () => {
+ const dot = document.getElementById("connection-dot");
+ if (dot) dot.classList.add("connected");
+ };
+ this._ws.onclose = () => {
+ const dot = document.getElementById("connection-dot");
+ if (dot) dot.classList.remove("connected");
+ };
+ this._ws.onmessage = (ev) => {
+ try {
+ const msg = JSON.parse(ev.data);
+ if (msg.type === "world_state" || msg.type === "timmy_state") {
+ if (msg.timmyState) this.state.timmyState = msg.timmyState;
+ if (msg.mood) {
+ this.state.timmyState.mood = msg.mood;
+ this.state.timmyState.activity = msg.activity || "";
+ this.state.timmyState.energy = msg.energy ?? 0.5;
+ }
+ this._notify();
+ }
+ } catch (e) {
+ /* ignore parse errors */
+ }
+ };
+ } catch (e) {
+ console.warn("WebSocket unavailable — using static state");
+ }
+ }
+
+ /** Current mood string. */
+ get mood() {
+ return this.state.timmyState.mood;
+ }
+
+ /** Current activity string. */
+ get activity() {
+ return this.state.timmyState.activity;
+ }
+
+ /** Energy level 0-1. */
+ get energy() {
+ return this.state.timmyState.energy;
+ }
+}
diff --git a/static/world/style.css b/static/world/style.css
new file mode 100644
index 0000000..e65e69d
--- /dev/null
+++ b/static/world/style.css
@@ -0,0 +1,89 @@
+/* Workshop 3D scene overlay styles */
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ overflow: hidden;
+ background: #0a0a14;
+ font-family: "Courier New", monospace;
+ color: #e0e0e0;
+ touch-action: none;
+}
+
+canvas {
+ display: block;
+}
+
+#overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ z-index: 10;
+}
+
+#status {
+ position: absolute;
+ top: 16px;
+ left: 16px;
+ font-size: 14px;
+ opacity: 0.8;
+}
+
+#status .name {
+ font-size: 18px;
+ font-weight: bold;
+ color: #daa520;
+}
+
+#status .mood {
+ font-size: 13px;
+ color: #aaa;
+ margin-top: 4px;
+}
+
+#speech-area {
+ position: absolute;
+ bottom: 24px;
+ left: 50%;
+ transform: translateX(-50%);
+ max-width: 480px;
+ width: 90%;
+ text-align: center;
+ font-size: 15px;
+ line-height: 1.5;
+ color: #ccc;
+ opacity: 0;
+ transition: opacity 0.4s ease;
+}
+
+#speech-area.visible {
+ opacity: 1;
+}
+
+#speech-area .bubble {
+ background: rgba(10, 10, 20, 0.85);
+ border: 1px solid rgba(218, 165, 32, 0.3);
+ border-radius: 8px;
+ padding: 12px 20px;
+}
+
+#connection-dot {
+ position: absolute;
+ top: 18px;
+ right: 16px;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #555;
+}
+
+#connection-dot.connected {
+ background: #00b450;
+}
diff --git a/static/world/wizard.js b/static/world/wizard.js
new file mode 100644
index 0000000..d6f08d0
--- /dev/null
+++ b/static/world/wizard.js
@@ -0,0 +1,99 @@
+/**
+ * Timmy the Wizard — geometric figure built from primitives.
+ *
+ * Phase 1: cone body (robe), sphere head, cylinder arms.
+ * Idle animation: gentle breathing (Y-scale oscillation), head tilt.
+ */
+
+import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
+
+const ROBE_COLOR = 0x2d1b4e;
+const TRIM_COLOR = 0xdaa520;
+
+/**
+ * Create the wizard group and return { group, update }.
+ * Call update(dt) each frame for idle animation.
+ */
+export function createWizard() {
+ const group = new THREE.Group();
+
+ // --- Robe (cone) ---
+ const robeGeo = new THREE.ConeGeometry(0.5, 1.6, 8);
+ const robeMat = new THREE.MeshStandardMaterial({
+ color: ROBE_COLOR,
+ roughness: 0.8,
+ });
+ const robe = new THREE.Mesh(robeGeo, robeMat);
+ robe.position.y = 0.8;
+ group.add(robe);
+
+ // --- Trim ring at robe bottom ---
+ const trimGeo = new THREE.TorusGeometry(0.5, 0.03, 8, 24);
+ const trimMat = new THREE.MeshStandardMaterial({
+ color: TRIM_COLOR,
+ roughness: 0.4,
+ metalness: 0.3,
+ });
+ const trim = new THREE.Mesh(trimGeo, trimMat);
+ trim.rotation.x = Math.PI / 2;
+ trim.position.y = 0.02;
+ group.add(trim);
+
+ // --- Head (sphere) ---
+ const headGeo = new THREE.SphereGeometry(0.22, 12, 10);
+ const headMat = new THREE.MeshStandardMaterial({
+ color: 0xd4a574,
+ roughness: 0.7,
+ });
+ const head = new THREE.Mesh(headGeo, headMat);
+ head.position.y = 1.72;
+ group.add(head);
+
+ // --- Hood (cone behind head) ---
+ const hoodGeo = new THREE.ConeGeometry(0.35, 0.5, 8);
+ const hoodMat = new THREE.MeshStandardMaterial({
+ color: ROBE_COLOR,
+ roughness: 0.8,
+ });
+ const hood = new THREE.Mesh(hoodGeo, hoodMat);
+ hood.position.y = 1.85;
+ hood.position.z = -0.08;
+ group.add(hood);
+
+ // --- Arms (cylinders) ---
+ const armGeo = new THREE.CylinderGeometry(0.06, 0.08, 0.7, 6);
+ const armMat = new THREE.MeshStandardMaterial({
+ color: ROBE_COLOR,
+ roughness: 0.8,
+ });
+
+ const leftArm = new THREE.Mesh(armGeo, armMat);
+ leftArm.position.set(-0.45, 1.0, 0.15);
+ leftArm.rotation.z = 0.3;
+ leftArm.rotation.x = -0.4;
+ group.add(leftArm);
+
+ const rightArm = new THREE.Mesh(armGeo, armMat);
+ rightArm.position.set(0.45, 1.0, 0.15);
+ rightArm.rotation.z = -0.3;
+ rightArm.rotation.x = -0.4;
+ group.add(rightArm);
+
+ // Position behind the desk
+ group.position.set(0, 0, -0.8);
+
+ // Animation state
+ let elapsed = 0;
+
+ function update(dt) {
+ elapsed += dt;
+ // Breathing: subtle Y-scale oscillation
+ const breath = 1.0 + Math.sin(elapsed * 1.5) * 0.015;
+ robe.scale.y = breath;
+ // Head tilt
+ head.rotation.z = Math.sin(elapsed * 0.7) * 0.05;
+ head.rotation.x = Math.sin(elapsed * 0.5) * 0.03;
+ }
+
+ return { group, update };
+}