Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>dispatchAI — Tiny Model Dungeon</title> | |
| <style> | |
| :root { | |
| --ink: #0A0F1A; | |
| --off-white: #F5F7FA; | |
| --electric-blue: #2E6BFF; | |
| --cyan: #1FE0E6; | |
| --dungeon-dark: #0d0d14; | |
| --dungeon-bg: #14141f; | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: 'Courier New', 'SF Mono', monospace; | |
| background: var(--dungeon-dark); | |
| color: var(--off-white); | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| padding: 10px; | |
| } | |
| .header { | |
| text-align: center; | |
| margin-bottom: 15px; | |
| } | |
| .title { | |
| font-size: 1.8em; | |
| font-weight: bold; | |
| color: var(--cyan); | |
| text-shadow: 0 0 20px rgba(31, 224, 230, 0.3); | |
| } | |
| .subtitle { | |
| color: #666; | |
| font-size: 0.75em; | |
| margin-top: 3px; | |
| } | |
| .game-container { | |
| width: 100%; | |
| max-width: 800px; | |
| background: var(--dungeon-bg); | |
| border: 2px solid #1a2332; | |
| border-radius: 8px; | |
| overflow: hidden; | |
| display: flex; | |
| flex-direction: column; | |
| height: 600px; | |
| } | |
| .game-header { | |
| background: linear-gradient(135deg, rgba(46, 107, 255, 0.1), rgba(31, 224, 230, 0.1)); | |
| padding: 8px 15px; | |
| border-bottom: 1px solid #1a2332; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| font-size: 0.8em; | |
| } | |
| .hp-bar { | |
| display: flex; | |
| align-items: center; | |
| gap: 5px; | |
| } | |
| .hp-track { | |
| width: 100px; | |
| height: 8px; | |
| background: #1a2332; | |
| border-radius: 4px; | |
| overflow: hidden; | |
| } | |
| .hp-fill { | |
| height: 100%; | |
| background: linear-gradient(90deg, #ff4444, #ffaa00); | |
| transition: width 0.3s; | |
| } | |
| .game-screen { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 15px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| .game-screen::-webkit-scrollbar { width: 6px; } | |
| .game-screen::-webkit-scrollbar-thumb { background: #1a2332; border-radius: 3px; } | |
| .narration { | |
| color: #aab4c8; | |
| font-size: 0.9em; | |
| line-height: 1.6; | |
| white-space: pre-wrap; | |
| } | |
| .narration.ai { | |
| color: var(--cyan); | |
| border-left: 3px solid var(--cyan); | |
| padding-left: 10px; | |
| } | |
| .action { | |
| color: var(--electric-blue); | |
| font-size: 0.85em; | |
| font-style: italic; | |
| } | |
| .system { | |
| color: #555; | |
| font-size: 0.75em; | |
| text-align: center; | |
| font-style: italic; | |
| } | |
| .input-area { | |
| padding: 10px 15px; | |
| border-top: 1px solid #1a2332; | |
| display: flex; | |
| gap: 8px; | |
| } | |
| .input-area input { | |
| flex: 1; | |
| background: var(--dungeon-dark); | |
| border: 1px solid #1a2332; | |
| color: var(--off-white); | |
| padding: 10px 14px; | |
| border-radius: 6px; | |
| font-family: inherit; | |
| font-size: 0.9em; | |
| outline: none; | |
| } | |
| .input-area input:focus { border-color: var(--cyan); } | |
| .input-area input:disabled { opacity: 0.5; } | |
| .input-area button { | |
| background: var(--cyan); | |
| color: var(--ink); | |
| border: none; | |
| padding: 10px 20px; | |
| border-radius: 6px; | |
| font-family: inherit; | |
| font-weight: bold; | |
| font-size: 0.85em; | |
| cursor: pointer; | |
| } | |
| .input-area button:disabled { opacity: 0.5; cursor: not-allowed; } | |
| .quick-actions { | |
| display: flex; | |
| gap: 5px; | |
| padding: 0 15px 10px; | |
| flex-wrap: wrap; | |
| } | |
| .quick-btn { | |
| background: #1a2332; | |
| border: 1px solid #233; | |
| color: #8892a6; | |
| padding: 5px 12px; | |
| border-radius: 4px; | |
| font-family: inherit; | |
| font-size: 0.75em; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .quick-btn:hover { border-color: var(--cyan); color: var(--cyan); } | |
| .stats-bar { | |
| width: 100%; | |
| max-width: 800px; | |
| margin-top: 10px; | |
| display: flex; | |
| gap: 8px; | |
| flex-wrap: wrap; | |
| justify-content: center; | |
| } | |
| .stat { | |
| background: var(--dungeon-bg); | |
| border: 1px solid #1a2332; | |
| border-radius: 4px; | |
| padding: 5px 10px; | |
| font-size: 0.7em; | |
| color: #666; | |
| } | |
| .stat span { color: var(--cyan); } | |
| .footer { | |
| margin-top: 15px; | |
| text-align: center; | |
| color: #444; | |
| font-size: 0.7em; | |
| } | |
| .footer a { color: var(--electric-blue); text-decoration: none; } | |
| .loading-overlay { | |
| position: fixed; | |
| inset: 0; | |
| background: rgba(10, 15, 26, 0.95); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 100; | |
| gap: 15px; | |
| } | |
| .loading-overlay.hidden { display: none; } | |
| .loading-spinner { | |
| width: 40px; | |
| height: 40px; | |
| border: 3px solid #1a2332; | |
| border-top-color: var(--cyan); | |
| border-radius: 50%; | |
| animation: spin 1s linear infinite; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| .start-btn { | |
| background: linear-gradient(135deg, var(--electric-blue), var(--cyan)); | |
| color: var(--ink); | |
| border: none; | |
| padding: 15px 40px; | |
| border-radius: 8px; | |
| font-family: inherit; | |
| font-weight: bold; | |
| font-size: 1.1em; | |
| cursor: pointer; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <div class="title">⚔️ Tiny Model Dungeon</div> | |
| <div class="subtitle">An AI text adventure powered by a 360M parameter model running in your browser</div> | |
| </div> | |
| <div class="loading-overlay" id="loadingOverlay"> | |
| <div class="loading-spinner"></div> | |
| <div id="loadingText" style="color: var(--cyan); font-size: 0.9em;">Loading the dungeon master (360M model)...</div> | |
| <div style="color: #555; font-size: 0.75em;">~200MB download, cached for future visits</div> | |
| </div> | |
| <div class="game-container"> | |
| <div class="game-header"> | |
| <span style="color: var(--cyan);">Dungeon Level 1</span> | |
| <div class="hp-bar"> | |
| <span style="color: #ff4444;">HP</span> | |
| <div class="hp-track"><div class="hp-fill" id="hpFill" style="width: 100%"></div></div> | |
| <span id="hpText" style="color: #8892a6; font-size: 0.85em;">100/100</span> | |
| </div> | |
| </div> | |
| <div class="game-screen" id="gameScreen"> | |
| <div class="system">The dungeon awaits... Click "Enter" to begin your adventure.</div> | |
| </div> | |
| <div class="quick-actions"> | |
| <button class="quick-btn" onclick="quickAction('look around')" disabled>Look around</button> | |
| <button class="quick-btn" onclick="quickAction('check inventory')" disabled>Inventory</button> | |
| <button class="quick-btn" onclick="quickAction('attack')" disabled>Attack</button> | |
| <button class="quick-btn" onclick="quickAction('flee')" disabled>Flee</button> | |
| <button class="quick-btn" onclick="quickAction('search for treasure')" disabled>Search</button> | |
| </div> | |
| <div class="input-area"> | |
| <input type="text" id="input" placeholder="What do you do?" disabled autocomplete="off"> | |
| <button id="sendBtn" disabled>Go</button> | |
| </div> | |
| </div> | |
| <div class="stats-bar"> | |
| <div class="stat">Model: <span>SmolLM2-360M</span></div> | |
| <div class="stat">Size: <span>~200MB</span></div> | |
| <div class="stat">Backend: <span id="backend">—</span></div> | |
| <div class="stat">Tokens/s: <span id="tps">—</span></div> | |
| </div> | |
| <div class="footer"> | |
| The entire game AI fits in 200MB and runs on your device. No server, no cloud, no cost.<br> | |
| <a href="https://huggingface.co/dispatchAI" target="_blank">dispatchAI</a> | | |
| <a href="https://huggingface.co/dispatchAI/SmolLM2-360M-Instruct-mobile" target="_blank">Model</a> | | |
| <a href="https://github.com/xenova/transformers.js" target="_blank">transformers.js</a> | |
| </div> | |
| <script type="module"> | |
| import { pipeline, env } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.7.0'; | |
| env.allowLocalModels = false; | |
| env.useBrowserCache = true; | |
| let generator = null; | |
| let isGenerating = false; | |
| let hp = 100; | |
| let gameState = { room: 1, inventory: [], hasSword: false, monstersDefeated: 0 }; | |
| const loadingOverlay = document.getElementById('loadingOverlay'); | |
| const loadingText = document.getElementById('loadingText'); | |
| const gameScreen = document.getElementById('gameScreen'); | |
| const input = document.getElementById('input'); | |
| const sendBtn = document.getElementById('sendBtn'); | |
| const hpFill = document.getElementById('hpFill'); | |
| const hpText = document.getElementById('hpText'); | |
| const tpsEl = document.getElementById('tps'); | |
| const backendEl = document.getElementById('backend'); | |
| const SYSTEM_PROMPT = `You are the dungeon master in a text adventure game. The player explores a dungeon. Describe rooms, monsters, and treasure vividly but concisely (2-3 sentences). React to player actions creatively. If the player attacks, describe the combat. If they search, describe what they find. Keep the adventure exciting and dangerous.`; | |
| const MODEL_ID = 'onnx-community/SmolLM2-360M-Instruct-ONNX'; | |
| function addMessage(text, type) { | |
| const div = document.createElement('div'); | |
| div.className = type; | |
| div.textContent = text; | |
| gameScreen.appendChild(div); | |
| gameScreen.scrollTop = gameScreen.scrollHeight; | |
| } | |
| async function initGame() { | |
| loadingText.textContent = 'Checking WebGPU support...'; | |
| let device = 'wasm'; | |
| let dtype = 'q8'; | |
| if (navigator.gpu) { | |
| try { | |
| const adapter = await navigator.gpu.requestAdapter(); | |
| if (adapter) { device = 'webgpu'; dtype = 'q4f16'; } | |
| } catch(e) {} | |
| } | |
| backendEl.textContent = device.toUpperCase(); | |
| loadingText.textContent = `Downloading dungeon master (${device.toUpperCase()})...`; | |
| const t0 = performance.now(); | |
| try { | |
| generator = await pipeline('text-generation', MODEL_ID, { | |
| device: device, | |
| dtype: dtype, | |
| }); | |
| const loadTime = ((performance.now() - t0) / 1000).toFixed(1); | |
| loadingText.textContent = `Loaded in ${loadTime}s!`; | |
| setTimeout(() => { | |
| loadingOverlay.classList.add('hidden'); | |
| startGame(); | |
| }, 500); | |
| } catch(e) { | |
| loadingText.textContent = `Error: ${e.message}`; | |
| console.error(e); | |
| } | |
| } | |
| function startGame() { | |
| addMessage('You stand before the entrance to an ancient dungeon. The air is cold. Darkness stretches below.', 'narration ai'); | |
| addMessage('Type your action or use the quick buttons above. Good luck, adventurer.', 'system'); | |
| input.disabled = false; | |
| sendBtn.disabled = false; | |
| document.querySelectorAll('.quick-btn').forEach(b => b.disabled = false); | |
| input.focus(); | |
| } | |
| async function takeAction(action) { | |
| if (isGenerating || !generator) return; | |
| if (!action.trim()) return; | |
| input.value = ''; | |
| input.disabled = true; | |
| sendBtn.disabled = true; | |
| isGenerating = true; | |
| document.querySelectorAll('.quick-btn').forEach(b => b.disabled = true); | |
| addMessage(`> ${action}`, 'action'); | |
| const messages = [ | |
| { role: 'system', content: SYSTEM_PROMPT }, | |
| { role: 'user', content: action }, | |
| ]; | |
| try { | |
| const t0 = performance.now(); | |
| const output = await generator(messages, { | |
| max_new_tokens: 100, | |
| do_sample: true, | |
| temperature: 0.8, | |
| }); | |
| const elapsed = (performance.now() - t0) / 1000; | |
| const text = output[0].generated_text[2].content; | |
| const tokenCount = Math.ceil(text.length / 4); | |
| const tps = (tokenCount / elapsed).toFixed(1); | |
| tpsEl.textContent = `${tps}`; | |
| // Type out the narration | |
| const aiMsg = document.createElement('div'); | |
| aiMsg.className = 'narration ai'; | |
| gameScreen.appendChild(aiMsg); | |
| let i = 0; | |
| const typeInterval = setInterval(() => { | |
| if (i < text.length) { | |
| aiMsg.textContent = text.slice(0, i + 1); | |
| gameScreen.scrollTop = gameScreen.scrollHeight; | |
| i++; | |
| } else { | |
| clearInterval(typeInterval); | |
| } | |
| }, 15); | |
| // Random HP changes for game feel | |
| if (action.toLowerCase().includes('attack')) { | |
| const dmg = Math.floor(Math.random() * 20) + 5; | |
| hp = Math.max(0, hp - dmg); | |
| updateHP(); | |
| if (hp <= 0) { | |
| setTimeout(() => addMessage('You have fallen. The dungeon claims another soul... Refresh to try again.', 'system'), 2000); | |
| } | |
| } | |
| if (action.toLowerCase().includes('search') || action.toLowerCase().includes('treasure')) { | |
| if (Math.random() > 0.5) { | |
| hp = Math.min(100, hp + 10); | |
| updateHP(); | |
| } | |
| } | |
| } catch(e) { | |
| addMessage(`Error: ${e.message}`, 'system'); | |
| } finally { | |
| input.disabled = false; | |
| sendBtn.disabled = false; | |
| isGenerating = false; | |
| document.querySelectorAll('.quick-btn').forEach(b => b.disabled = false); | |
| input.focus(); | |
| } | |
| } | |
| function updateHP() { | |
| hpFill.style.width = `${hp}%`; | |
| hpText.textContent = `${hp}/100`; | |
| } | |
| window.quickAction = takeAction; | |
| sendBtn.addEventListener('click', () => takeAction(input.value)); | |
| input.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') takeAction(input.value); | |
| }); | |
| // Start loading immediately | |
| initGame(); | |
| </script> | |
| </body> | |
| </html> | |