diff --git a/icons/icon-192.svg b/icons/icon-192.svg
new file mode 100644
index 0000000..ef04043
--- /dev/null
+++ b/icons/icon-192.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/icons/icon-512.svg b/icons/icon-512.svg
new file mode 100644
index 0000000..47bcec0
--- /dev/null
+++ b/icons/icon-512.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/index.html b/index.html
index 9e3624f..862a9d4 100644
--- a/index.html
+++ b/index.html
@@ -2,12 +2,34 @@
TIMMY TOWER WORLD
@@ -56,5 +87,11 @@
OFFLINE
+
diff --git a/js/effects.js b/js/effects.js
index b514b53..513c549 100644
--- a/js/effects.js
+++ b/js/effects.js
@@ -1,22 +1,27 @@
import * as THREE from 'three';
+import { getQualityTier } from './quality.js';
let rainParticles;
let rainPositions;
let rainVelocities;
-const RAIN_COUNT = 2000;
+let rainCount = 0;
export function initEffects(scene) {
- initMatrixRain(scene);
- initStarfield(scene);
+ const tier = getQualityTier();
+ initMatrixRain(scene, tier);
+ initStarfield(scene, tier);
}
-function initMatrixRain(scene) {
- const geo = new THREE.BufferGeometry();
- const positions = new Float32Array(RAIN_COUNT * 3);
- const velocities = new Float32Array(RAIN_COUNT);
- const colors = new Float32Array(RAIN_COUNT * 3);
+function initMatrixRain(scene, tier) {
+ // Scale particle count by quality tier
+ rainCount = tier === 'low' ? 500 : tier === 'medium' ? 1200 : 2000;
- for (let i = 0; i < RAIN_COUNT; i++) {
+ const geo = new THREE.BufferGeometry();
+ const positions = new Float32Array(rainCount * 3);
+ const velocities = new Float32Array(rainCount);
+ const colors = new Float32Array(rainCount * 3);
+
+ for (let i = 0; i < rainCount; i++) {
positions[i * 3] = (Math.random() - 0.5) * 100;
positions[i * 3 + 1] = Math.random() * 50 + 5;
positions[i * 3 + 2] = (Math.random() - 0.5) * 100;
@@ -34,7 +39,7 @@ function initMatrixRain(scene) {
rainVelocities = velocities;
const mat = new THREE.PointsMaterial({
- size: 0.12,
+ size: tier === 'low' ? 0.16 : 0.12,
vertexColors: true,
transparent: true,
opacity: 0.7,
@@ -45,8 +50,8 @@ function initMatrixRain(scene) {
scene.add(rainParticles);
}
-function initStarfield(scene) {
- const count = 500;
+function initStarfield(scene, tier) {
+ const count = tier === 'low' ? 150 : tier === 'medium' ? 350 : 500;
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
@@ -72,7 +77,7 @@ function initStarfield(scene) {
export function updateEffects(_time) {
if (!rainParticles) return;
- for (let i = 0; i < RAIN_COUNT; i++) {
+ for (let i = 0; i < rainCount; i++) {
rainPositions[i * 3 + 1] -= rainVelocities[i];
if (rainPositions[i * 3 + 1] < -1) {
rainPositions[i * 3 + 1] = 40 + Math.random() * 20;
diff --git a/js/main.js b/js/main.js
index df4b2c0..8040cf9 100644
--- a/js/main.js
+++ b/js/main.js
@@ -2,7 +2,7 @@ import { initWorld, onWindowResize } from './world.js';
import { initAgents, updateAgents, getAgentCount } from './agents.js';
import { initEffects, updateEffects } from './effects.js';
import { initUI, updateUI } from './ui.js';
-import { initInteraction } from './interaction.js';
+import { initInteraction, updateControls } from './interaction.js';
import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
let frameCount = 0;
@@ -20,6 +20,10 @@ function main() {
window.addEventListener('resize', () => onWindowResize(camera, renderer));
+ // Dismiss loading screen
+ const loadingScreen = document.getElementById('loading-screen');
+ if (loadingScreen) loadingScreen.classList.add('hidden');
+
function animate() {
requestAnimationFrame(animate);
@@ -31,6 +35,7 @@ function main() {
lastFpsTime = now;
}
+ updateControls();
updateEffects(now);
updateAgents(now);
updateUI({
diff --git a/js/quality.js b/js/quality.js
new file mode 100644
index 0000000..3ff51bc
--- /dev/null
+++ b/js/quality.js
@@ -0,0 +1,90 @@
+/**
+ * quality.js — Detect hardware capability and return a quality tier.
+ *
+ * Tiers:
+ * 'low' — older iPads, phones, low-end GPUs (reduce particles, simpler effects)
+ * 'medium' — mid-range (moderate particle count)
+ * 'high' — desktop, modern iPad Pro (full quality)
+ *
+ * Detection uses a combination of:
+ * - Device pixel ratio (low DPR = likely low-end)
+ * - Logical core count (navigator.hardwareConcurrency)
+ * - Device memory (navigator.deviceMemory, Chrome/Edge only)
+ * - Screen size (small viewport = likely mobile)
+ * - Touch capability (touch + small screen = phone/tablet)
+ * - WebGL renderer string (if available)
+ */
+
+let cachedTier = null;
+
+export function getQualityTier() {
+ if (cachedTier) return cachedTier;
+
+ let score = 0;
+
+ // Core count: 1-2 = low, 4 = mid, 8+ = high
+ const cores = navigator.hardwareConcurrency || 2;
+ if (cores >= 8) score += 3;
+ else if (cores >= 4) score += 2;
+ else score += 0;
+
+ // Device memory (Chrome/Edge): < 4GB = low, 4-8 = mid, 8+ = high
+ const mem = navigator.deviceMemory || 4;
+ if (mem >= 8) score += 3;
+ else if (mem >= 4) score += 2;
+ else score += 0;
+
+ // Screen dimensions (logical pixels)
+ const maxDim = Math.max(window.screen.width, window.screen.height);
+ if (maxDim < 768) score -= 1; // phone
+ else if (maxDim >= 1920) score += 1; // large desktop
+
+ // DPR: high DPR on small screens = more GPU work
+ const dpr = window.devicePixelRatio || 1;
+ if (dpr > 2 && maxDim < 1024) score -= 1; // retina phone
+
+ // Touch-only device heuristic
+ const touchOnly = 'ontouchstart' in window && !window.matchMedia('(pointer: fine)').matches;
+ if (touchOnly) score -= 1;
+
+ // Try reading WebGL renderer for GPU hints
+ try {
+ const canvas = document.createElement('canvas');
+ const gl = canvas.getContext('webgl') || canvas.getContext('webgl2');
+ if (gl) {
+ const debugExt = gl.getExtension('WEBGL_debug_renderer_info');
+ if (debugExt) {
+ const renderer = gl.getParameter(debugExt.UNMASKED_RENDERER_WEBGL).toLowerCase();
+ // Known low-end GPU strings
+ if (renderer.includes('swiftshader') || renderer.includes('llvmpipe') || renderer.includes('software')) {
+ score -= 3; // software renderer
+ }
+ if (renderer.includes('apple gpu') || renderer.includes('apple m')) {
+ score += 2; // Apple Silicon is good
+ }
+ }
+ gl.getExtension('WEBGL_lose_context')?.loseContext();
+ }
+ } catch {
+ // Can't probe GPU, use other signals
+ }
+
+ // Map score to tier
+ if (score <= 1) cachedTier = 'low';
+ else if (score <= 4) cachedTier = 'medium';
+ else cachedTier = 'high';
+
+ console.info(`[Matrix Quality] Tier: ${cachedTier} (score: ${score}, cores: ${cores}, mem: ${mem}GB, dpr: ${dpr}, touch: ${touchOnly})`);
+
+ return cachedTier;
+}
+
+/**
+ * Get the recommended pixel ratio cap for the renderer.
+ */
+export function getMaxPixelRatio() {
+ const tier = getQualityTier();
+ if (tier === 'low') return 1;
+ if (tier === 'medium') return 1.5;
+ return 2;
+}
diff --git a/js/ui.js b/js/ui.js
index 7ea5193..6a3b073 100644
--- a/js/ui.js
+++ b/js/ui.js
@@ -11,6 +11,9 @@ const $chatPanel = document.getElementById('chat-panel');
const MAX_CHAT_ENTRIES = 12;
const chatEntries = [];
+const IDLE_COLOR = '#005500';
+const ACTIVE_COLOR = '#00ff41';
+
export function initUI() {
renderAgentList();
}
@@ -18,12 +21,14 @@ export function initUI() {
function renderAgentList() {
const defs = getAgentDefs();
$agentList.innerHTML = defs.map(a => {
- const css = colorToCss(a.color);
+ const css = escapeAttr(colorToCss(a.color));
+ const safeLabel = escapeHtml(a.label);
+ const safeId = escapeAttr(a.id);
return `
[
- ${a.label}
+ ${safeLabel}
]
- IDLE
+ IDLE
`;
}).join('');
}
@@ -49,7 +54,7 @@ export function updateUI({ fps, agentCount, jobCount, connectionState }) {
const el = document.getElementById(`agent-state-${a.id}`);
if (el) {
el.textContent = ` ${a.state.toUpperCase()}`;
- el.style.color = a.state === 'active' ? '#00ff41' : '#003300';
+ el.style.color = a.state === 'active' ? ACTIVE_COLOR : IDLE_COLOR;
}
});
}
@@ -61,24 +66,42 @@ export function updateUI({ fps, agentCount, jobCount, connectionState }) {
* @param {string} cssColor — CSS color string, e.g. '#00ff88'
*/
export function appendChatMessage(agentLabel, message, cssColor) {
- const color = cssColor || '#00ff41';
+ const color = escapeAttr(cssColor || '#00ff41');
const entry = document.createElement('div');
entry.className = 'chat-entry';
- entry.innerHTML = `
${agentLabel}: ${escapeHtml(message)}`;
+ entry.innerHTML = `
${escapeHtml(agentLabel)}: ${escapeHtml(message)}`;
+
chatEntries.push(entry);
- if (chatEntries.length > MAX_CHAT_ENTRIES) {
+ while (chatEntries.length > MAX_CHAT_ENTRIES) {
const removed = chatEntries.shift();
- $chatPanel.removeChild(removed);
+ try { $chatPanel.removeChild(removed); } catch { /* already removed */ }
}
$chatPanel.appendChild(entry);
$chatPanel.scrollTop = $chatPanel.scrollHeight;
}
+/**
+ * Escape HTML text content — prevents tag injection.
+ */
function escapeHtml(str) {
- return str
+ return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+/**
+ * Escape a value for use inside an HTML attribute (style="...", id="...").
+ */
+function escapeAttr(str) {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ .replace(//g, '>');
}
diff --git a/js/websocket.js b/js/websocket.js
index 0a758c8..a219a12 100644
--- a/js/websocket.js
+++ b/js/websocket.js
@@ -10,7 +10,15 @@ let ws = null;
let connectionState = 'disconnected';
let jobCount = 0;
let reconnectTimer = null;
-const RECONNECT_DELAY_MS = 5000;
+let reconnectAttempts = 0;
+
+const RECONNECT_BASE_MS = 2000;
+const RECONNECT_MAX_MS = 30000;
+const HEARTBEAT_INTERVAL_MS = 30000;
+const HEARTBEAT_TIMEOUT_MS = 5000;
+
+let heartbeatTimer = null;
+let heartbeatTimeout = null;
export function initWebSocket(_scene) {
if (!WS_URL) {
@@ -30,7 +38,9 @@ function connect() {
try {
ws = new WebSocket(WS_URL);
- } catch {
+ } catch (err) {
+ console.warn('[Matrix WS] Connection failed:', err.message || err);
+ logEvent('WebSocket connection failed');
connectionState = 'disconnected';
scheduleReconnect();
return;
@@ -38,7 +48,9 @@ function connect() {
ws.onopen = () => {
connectionState = 'connected';
+ reconnectAttempts = 0;
clearTimeout(reconnectTimer);
+ startHeartbeat();
ws.send(JSON.stringify({
type: 'subscribe',
channel: 'agents',
@@ -47,27 +59,75 @@ function connect() {
};
ws.onmessage = (event) => {
+ // Any message counts as a heartbeat response
+ resetHeartbeatTimeout();
try {
handleMessage(JSON.parse(event.data));
- } catch {
+ } catch (err) {
+ console.warn('[Matrix WS] Message parse/handle error:', err.message, '| raw:', event.data?.slice?.(0, 200));
}
};
- ws.onerror = () => {
+ ws.onerror = (event) => {
+ console.warn('[Matrix WS] Error event:', event);
connectionState = 'disconnected';
};
- ws.onclose = () => {
+ ws.onclose = (event) => {
connectionState = 'disconnected';
+ stopHeartbeat();
+
+ // Don't reconnect on clean close (1000) or going away (1001)
+ if (event.code === 1000 || event.code === 1001) {
+ console.info('[Matrix WS] Clean close (code ' + event.code + '), not reconnecting');
+ return;
+ }
+
+ console.warn('[Matrix WS] Unexpected close — code:', event.code, 'reason:', event.reason || '(none)');
scheduleReconnect();
};
}
function scheduleReconnect() {
clearTimeout(reconnectTimer);
- reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS);
+ // Exponential backoff: 2s, 4s, 8s, 16s, capped at 30s
+ const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, reconnectAttempts), RECONNECT_MAX_MS);
+ reconnectAttempts++;
+ console.info('[Matrix WS] Reconnecting in', Math.round(delay / 1000), 's (attempt', reconnectAttempts + ')');
+ reconnectTimer = setTimeout(connect, delay);
}
+/* ── Heartbeat / zombie connection detection ── */
+
+function startHeartbeat() {
+ stopHeartbeat();
+ heartbeatTimer = setInterval(() => {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ try {
+ ws.send(JSON.stringify({ type: 'ping' }));
+ } catch { /* ignore send failures, onclose will fire */ }
+ heartbeatTimeout = setTimeout(() => {
+ console.warn('[Matrix WS] Heartbeat timeout — closing zombie connection');
+ if (ws) ws.close(4000, 'heartbeat timeout');
+ }, HEARTBEAT_TIMEOUT_MS);
+ }
+ }, HEARTBEAT_INTERVAL_MS);
+}
+
+function stopHeartbeat() {
+ clearInterval(heartbeatTimer);
+ clearTimeout(heartbeatTimeout);
+ heartbeatTimer = null;
+ heartbeatTimeout = null;
+}
+
+function resetHeartbeatTimeout() {
+ clearTimeout(heartbeatTimeout);
+ heartbeatTimeout = null;
+}
+
+/* ── Message handler ── */
+
function handleMessage(msg) {
switch (msg.type) {
case 'agent_state': {
@@ -95,15 +155,17 @@ function handleMessage(msg) {
}
break;
}
+ case 'pong':
case 'agent_count':
break;
default:
+ console.debug('[Matrix WS] Unhandled message type:', msg.type);
break;
}
}
function logEvent(text) {
- appendChatMessage('SYS', text, colorToCss(0x003300));
+ appendChatMessage('SYS', text, '#005500');
}
export function getConnectionState() {
diff --git a/js/world.js b/js/world.js
index 1620eb1..e9bb286 100644
--- a/js/world.js
+++ b/js/world.js
@@ -1,4 +1,5 @@
import * as THREE from 'three';
+import { getMaxPixelRatio, getQualityTier } from './quality.js';
let scene, camera, renderer;
@@ -11,14 +12,15 @@ export function initWorld() {
camera.position.set(0, 12, 28);
camera.lookAt(0, 0, 0);
- renderer = new THREE.WebGLRenderer({ antialias: true });
+ const tier = getQualityTier();
+ renderer = new THREE.WebGLRenderer({ antialias: tier !== 'low' });
renderer.setSize(window.innerWidth, window.innerHeight);
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, getMaxPixelRatio()));
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.prepend(renderer.domElement);
addLights(scene);
- addGrid(scene);
+ addGrid(scene, tier);
return { scene, camera, renderer };
}
@@ -36,8 +38,9 @@ function addLights(scene) {
scene.add(fill);
}
-function addGrid(scene) {
- const grid = new THREE.GridHelper(100, 40, 0x003300, 0x001a00);
+function addGrid(scene, tier) {
+ const gridDivisions = tier === 'low' ? 20 : 40;
+ const grid = new THREE.GridHelper(100, gridDivisions, 0x003300, 0x001a00);
grid.position.y = -0.01;
scene.add(grid);
diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000..fd685f2
--- /dev/null
+++ b/manifest.json
@@ -0,0 +1,22 @@
+{
+ "name": "Timmy Tower World",
+ "short_name": "Tower World",
+ "description": "3D visualization of the Timmy agent network",
+ "start_url": "/",
+ "display": "standalone",
+ "orientation": "any",
+ "background_color": "#000000",
+ "theme_color": "#000000",
+ "icons": [
+ {
+ "src": "/icons/icon-192.svg",
+ "sizes": "192x192",
+ "type": "image/svg+xml"
+ },
+ {
+ "src": "/icons/icon-512.svg",
+ "sizes": "512x512",
+ "type": "image/svg+xml"
+ }
+ ]
+}
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..4726ebc
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..b49d106
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,48 @@
+/**
+ * Service Worker for Timmy Tower World PWA.
+ *
+ * Strategy: network-first with offline fallback.
+ * Caches the shell on install so the app works when iPad is offline.
+ */
+
+const CACHE_NAME = 'matrix-v1';
+const SHELL_URLS = [
+ '/',
+ '/index.html',
+ '/manifest.json',
+];
+
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS))
+ );
+ self.skipWaiting();
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches.keys().then((keys) =>
+ Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
+ )
+ );
+ self.clients.claim();
+});
+
+self.addEventListener('fetch', (event) => {
+ // Skip non-GET and WebSocket upgrade requests
+ if (event.request.method !== 'GET') return;
+ if (event.request.headers.get('upgrade') === 'websocket') return;
+
+ event.respondWith(
+ fetch(event.request)
+ .then((response) => {
+ // Cache successful responses
+ if (response.ok) {
+ const clone = response.clone();
+ caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
+ }
+ return response;
+ })
+ .catch(() => caches.match(event.request))
+ );
+});