README.md CHANGED
@@ -26,6 +26,7 @@ This project is built and submitted as part of the Hugging Face × Gradio **Buil
26
 
27
  ## 🔗 Submission Details
28
  * **Live Space URL**: [https://build-small-hackathon-grid-royale.hf.space](https://build-small-hackathon-grid-royale.hf.space)
 
29
  * **Demo Video:** *[todo]*
30
  * **Social Post:** *[todo]*
31
 
 
26
 
27
  ## 🔗 Submission Details
28
  * **Live Space URL**: [https://build-small-hackathon-grid-royale.hf.space](https://build-small-hackathon-grid-royale.hf.space)
29
+ * **Gradio Panel**: [https://build-small-hackathon-grid-royale.hf.space/gradio](https://build-small-hackathon-grid-royale.hf.space/gradio)
30
  * **Demo Video:** *[todo]*
31
  * **Social Post:** *[todo]*
32
 
api.py CHANGED
@@ -17,6 +17,23 @@ app.add_middleware(
17
  engine: Engine | None = None
18
 
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  class StartGameRequest(BaseModel):
21
  grid_size: int = 20
22
  max_turns: int = 50
 
17
  engine: Engine | None = None
18
 
19
 
20
+ @app.on_event("startup")
21
+ async def _seed_default_game():
22
+ """Pre-create a game so visitors see a live arena instead of a lobby."""
23
+ global engine
24
+ try:
25
+ cfg = GameConfig(grid_size=20, max_turns=100, max_agents=4, num_chests=15)
26
+ eng = Engine(config=cfg)
27
+ eng.generate_grid()
28
+ eng.scatter_chests()
29
+ for i, name in enumerate(_default_names(4)):
30
+ eng.spawn_agent(agent_id=f"agent_{i}", name=name)
31
+ engine = eng
32
+ except Exception as exc:
33
+ import logging
34
+ logging.getLogger("uvicorn.error").warning(f"Default game seed failed: {exc}")
35
+
36
+
37
  class StartGameRequest(BaseModel):
38
  grid_size: int = 20
39
  max_turns: int = 50
backend/agent/llm_client.py CHANGED
@@ -47,7 +47,7 @@ PROVIDER_CONFIG = {
47
  },
48
  }
49
 
50
- DEFAULT_PROVIDER = "modal"
51
 
52
 
53
  def get_llm_client(provider: str | None = None) -> AsyncOpenAI:
 
47
  },
48
  }
49
 
50
+ DEFAULT_PROVIDER = os.getenv("LLM_PROVIDER", "modal")
51
 
52
 
53
  def get_llm_client(provider: str | None = None) -> AsyncOpenAI:
backend/engine.py CHANGED
@@ -59,10 +59,16 @@ class Engine:
59
  pos=[x, y],
60
  hp=self.env.config.base_hp,
61
  )
 
 
 
 
 
 
62
  brain = Agent(
63
  agent_id=agent_id,
64
- model=model or "google/gemma-4-26B-A4B-it",
65
- provider=provider or "modal",
66
  system_prompt=system_prompt,
67
  config=self.env.config,
68
  )
 
59
  pos=[x, y],
60
  hp=self.env.config.base_hp,
61
  )
62
+ import os
63
+ default_provider = os.getenv("LLM_PROVIDER", "modal")
64
+ default_model = os.getenv(
65
+ "LLM_MODEL",
66
+ "qwen2.5:7b" if default_provider == "local" else "google/gemma-4-26B-A4B-it",
67
+ )
68
  brain = Agent(
69
  agent_id=agent_id,
70
+ model=model or default_model,
71
+ provider=provider or default_provider,
72
  system_prompt=system_prompt,
73
  config=self.env.config,
74
  )
frontend/static/index.html CHANGED
@@ -370,13 +370,24 @@ body {
370
  .camera-mode-badge {
371
  font-family: 'JetBrains Mono', monospace;
372
  font-size: 9px; text-transform: uppercase; letter-spacing: 0.15em;
373
- color: var(--purple);
374
- background: rgba(180,74,255,0.1);
375
- border: 1px solid rgba(180,74,255,0.25);
376
- padding: 3px 8px; border-radius: 5px;
377
- display: none;
 
 
 
 
 
 
 
 
 
 
 
 
378
  }
379
- .camera-mode-badge.visible { display: block; }
380
  .winner-announce {
381
  font-family: 'Space Grotesk', sans-serif;
382
  font-size: 12px; font-weight: 700; color: var(--gold);
@@ -866,6 +877,29 @@ body {
866
  flex: 1; overflow-y: auto; padding: 6px 10px;
867
  }
868
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
869
  /* ══════════════════════ CONTROLS ══════════════════════ */
870
  .controls-bar {
871
  display: flex; align-items: center; gap: 8px;
@@ -1153,7 +1187,8 @@ body {
1153
  </div>
1154
 
1155
  <div class="top-right">
1156
- <span class="camera-mode-badge" id="cameraModeBadge">👁 POV MODE</span>
 
1157
  <span class="winner-announce" id="winnerAnnounce"></span>
1158
  </div>
1159
  </div>
@@ -1265,6 +1300,50 @@ const AGENT_COLORS = {
1265
  agent_6: '#00e5ff', agent_7: '#ff9100',
1266
  };
1267
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1268
  /* ═══════════ LOBBY PARTICLES ═══════════ */
1269
  (function initLobbyParticles() {
1270
  // Disabled for minimal, clean design
@@ -2205,19 +2284,38 @@ function enterFps() {
2205
  app.controls.update();
2206
 
2207
  document.getElementById('fpsHud').classList.add('active');
2208
- const badge = document.getElementById('cameraModeBadge');
2209
- badge.textContent = '📹 CHASE CAM';
2210
- badge.classList.add('visible');
2211
  updateFpsHud(agent);
2212
  buildSidebar();
2213
  }
2214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2215
  function exitFps() {
2216
  fpsMode = false;
2217
  fpsCameraPos = null;
2218
  fpsLookTarget = null;
2219
  document.getElementById('fpsHud').classList.remove('active');
2220
- document.getElementById('cameraModeBadge').classList.remove('visible');
2221
  // Restore original orbit distances
2222
  if (app) {
2223
  app.controls.minDistance = 6;
@@ -2498,18 +2596,31 @@ function buildTabContent(aid, tab) {
2498
  function buildTraceForAgent(aid) {
2499
  const entries = traceHistory[aid] || [];
2500
  if (!entries.length) return '<div class="empty-state">No trace data yet</div>';
2501
- const color = AGENT_COLORS[aid] || '#888';
2502
  return entries.map(e => {
2503
- const turnStr = `t${e.turn}`;
2504
- if (e.phase==='exec') {
2505
- const args = JSON.stringify(e.args||{}).slice(0,100);
2506
- return `<div class="trace-row" style="border-left-color:${color}">
2507
- <div class="trace-label">${turnStr} · ${e.tool} <span style="color:var(--text-muted);font-weight:400">r${e.round}</span></div>
2508
- <div class="trace-content">${esc(args)}</div>
2509
- <div class="trace-content" style="color:var(--cyan);margin-top:2px">${esc((e.result||'').slice(0,200))}</div>
2510
- </div>`;
 
 
 
 
2511
  }
2512
- return '';
 
 
 
 
 
 
 
 
 
 
2513
  }).join('');
2514
  }
2515
 
@@ -2561,9 +2672,22 @@ function buildTracePanel() {
2561
  const name = agent?agent.name:aid, color=AGENT_COLORS[aid]||'#888';
2562
  const entries = log.agents[aid]||[];
2563
  entries.forEach(e => {
2564
- if (e.phase==='exec') {
2565
- html+=`<div class="trace-row" style="border-left-color:${color}"><div class="trace-label"><span style="color:${color}">${name}</span> <span style="color:var(--text)">${e.tool}</span></div><div class="trace-content">→ ${esc((e.result||'').slice(0,120))}</div></div>`;
 
 
 
 
2566
  }
 
 
 
 
 
 
 
 
 
2567
  });
2568
  }
2569
  html += '</div>';
@@ -2596,6 +2720,7 @@ function selectAgent(id) {
2596
  selectedId = id;
2597
  activeTab = 'trace';
2598
  buildSidebar();
 
2599
  if (id && state) {
2600
  const agent = (state.agents||[]).find(a=>a.id===id);
2601
  if (agent && app) {
@@ -2948,6 +3073,37 @@ document.addEventListener('keydown', e => {
2948
  });
2949
 
2950
  window.addEventListener('resize', ()=>{ if (app) app.resize(); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2951
  </script>
2952
  </body>
2953
  </html>
 
370
  .camera-mode-badge {
371
  font-family: 'JetBrains Mono', monospace;
372
  font-size: 9px; text-transform: uppercase; letter-spacing: 0.15em;
373
+ color: var(--text-dim);
374
+ background: rgba(255,255,255,0.04);
375
+ border: 1px solid var(--border);
376
+ padding: 4px 10px; border-radius: 5px;
377
+ cursor: pointer; transition: all 0.15s ease;
378
+ }
379
+ .camera-mode-badge:hover:not(:disabled) {
380
+ color: var(--purple); border-color: rgba(180,74,255,0.5);
381
+ background: rgba(180,74,255,0.08);
382
+ }
383
+ .camera-mode-badge.active {
384
+ color: var(--purple); border-color: rgba(180,74,255,0.5);
385
+ background: rgba(180,74,255,0.15);
386
+ box-shadow: 0 0 12px rgba(180,74,255,0.25);
387
+ }
388
+ .camera-mode-badge:disabled {
389
+ opacity: 0.4; cursor: not-allowed;
390
  }
 
391
  .winner-announce {
392
  font-family: 'Space Grotesk', sans-serif;
393
  font-size: 12px; font-weight: 700; color: var(--gold);
 
877
  flex: 1; overflow-y: auto; padding: 6px 10px;
878
  }
879
 
880
+ /* Visual trace chips */
881
+ .trace-chip {
882
+ display: inline-flex; align-items: center; gap: 3px;
883
+ padding: 1px 6px; border-radius: 8px;
884
+ font-size: 9px; font-weight: 700; letter-spacing: 0.04em;
885
+ font-family: 'JetBrains Mono', monospace;
886
+ }
887
+ .trace-chip.dmg { background: rgba(255,70,85,0.15); color: #ff7585; }
888
+ .trace-chip.score { background: rgba(251,191,36,0.15); color: #fbbf24; }
889
+ .trace-chip.pos { background: rgba(255,255,255,0.06); color: var(--text-dim); }
890
+ .trace-row.fail { border-left-color: #ff4655 !important; opacity: 0.72; }
891
+ .trace-row.fail .trace-label::before { content: '⚠ '; color: #ff4655; }
892
+ .trace-head {
893
+ display: flex; align-items: center; gap: 6px;
894
+ margin-bottom: 3px;
895
+ }
896
+ .trace-icon { font-size: 13px; line-height: 1; }
897
+ .trace-arrow { font-size: 15px; line-height: 1; font-weight: 700; }
898
+ .trace-tool { font-size: 9px; text-transform: uppercase; letter-spacing: 0.1em; font-weight: 700; }
899
+ .trace-round { font-size: 9px; color: var(--text-muted); font-weight: 400; }
900
+ .trace-turn { font-size: 9px; color: var(--text-muted); margin-left: auto; }
901
+ .trace-result { font-size: 9.5px; color: var(--text-dim); margin-top: 2px; }
902
+
903
  /* ══════════════════════ CONTROLS ══════════════════════ */
904
  .controls-bar {
905
  display: flex; align-items: center; gap: 8px;
 
1187
  </div>
1188
 
1189
  <div class="top-right">
1190
+ <button class="camera-mode-badge" id="newGameBtn" onclick="returnToLobby()" title="Open lobby to start a new game"> NEW GAME</button>
1191
+ <button class="camera-mode-badge" id="cameraModeBadge" onclick="togglePov()" title="Toggle chase cam (V) — select an agent first" disabled>📹 OVERVIEW</button>
1192
  <span class="winner-announce" id="winnerAnnounce"></span>
1193
  </div>
1194
  </div>
 
1300
  agent_6: '#00e5ff', agent_7: '#ff9100',
1301
  };
1302
 
1303
+ const TOOL_VIS = {
1304
+ observe: { icon: '👁', color: '#6ec5ff', label: 'OBSERVE' },
1305
+ think: { icon: '💭', color: '#a78bfa', label: 'THINK' },
1306
+ move: { icon: '➡', color: '#7dd3fc', label: 'MOVE' },
1307
+ attack: { icon: '⚔', color: '#ff4655', label: 'ATTACK' },
1308
+ dash: { icon: '💨', color: '#fbbf24', label: 'DASH' },
1309
+ shield: { icon: '🛡', color: '#34d399', label: 'SHIELD' },
1310
+ heal: { icon: '❤', color: '#f472b6', label: 'HEAL' },
1311
+ activate_ability: { icon: '✨', color: '#c084fc', label: 'ABILITY' },
1312
+ };
1313
+ const DEFAULT_VIS = { icon: '•', color: '#888', label: 'TOOL' };
1314
+ function ARROW_FOR(dx, dy) {
1315
+ dx = Math.sign(dx||0); dy = Math.sign(dy||0);
1316
+ if (dx===0 && dy<0) return '↑'; if (dx===0 && dy>0) return '↓';
1317
+ if (dx<0 && dy===0) return '←'; if (dx>0 && dy===0) return '→';
1318
+ if (dx<0 && dy<0) return '↖'; if (dx>0 && dy<0) return '↗';
1319
+ if (dx<0 && dy>0) return '↙'; if (dx>0 && dy>0) return '↘';
1320
+ return '·';
1321
+ }
1322
+ function pickVis(tool, args) {
1323
+ if (tool === 'activate_ability' && args && args.ability && TOOL_VIS[args.ability])
1324
+ return TOOL_VIS[args.ability];
1325
+ return TOOL_VIS[tool] || DEFAULT_VIS;
1326
+ }
1327
+ function outcomeChips(result) {
1328
+ const r = String(result || '');
1329
+ const chips = [];
1330
+ let fail = false;
1331
+ const score = r.match(/picked up (\d+) points?/i);
1332
+ if (score) chips.push(`<span class="trace-chip score">+${score[1]} pts</span>`);
1333
+ const loot = r.match(/picked up ability '([a-z_]+)'/i);
1334
+ if (loot) chips.push(`<span class="trace-chip score">+${esc(loot[1])}</span>`);
1335
+ const dmg = r.match(/for (\d+) damage/i);
1336
+ if (dmg) chips.push(`<span class="trace-chip dmg">−${dmg[1]} HP</span>`);
1337
+ const heal = r.match(/restored (\d+) HP/i);
1338
+ if (heal) chips.push(`<span class="trace-chip score">+${heal[1]} HP</span>`);
1339
+ const moved = r.match(/Moved to \((\d+),\s*(\d+)\)/i);
1340
+ if (moved) chips.push(`<span class="trace-chip pos">(${moved[1]},${moved[2]})</span>`);
1341
+ if (/eliminated|kill/i.test(r)) chips.push(`<span class="trace-chip dmg">☠ KILL</span>`);
1342
+ if (/shield (activated|broke)/i.test(r)) chips.push(`<span class="trace-chip score">🛡</span>`);
1343
+ if (/^(Invalid|cannot|don't have|can only|You cannot|Ability|No agent)/i.test(r) || /cooldown|no uses left/i.test(r)) fail = true;
1344
+ return { chips: chips.join(' '), fail };
1345
+ }
1346
+
1347
  /* ═══════════ LOBBY PARTICLES ═══════════ */
1348
  (function initLobbyParticles() {
1349
  // Disabled for minimal, clean design
 
2284
  app.controls.update();
2285
 
2286
  document.getElementById('fpsHud').classList.add('active');
2287
+ updateCameraBadge();
 
 
2288
  updateFpsHud(agent);
2289
  buildSidebar();
2290
  }
2291
 
2292
+ function updateCameraBadge() {
2293
+ const badge = document.getElementById('cameraModeBadge');
2294
+ if (!badge) return;
2295
+ if (fpsMode) {
2296
+ badge.textContent = '📹 EXIT CHASE';
2297
+ badge.classList.add('active');
2298
+ badge.disabled = false;
2299
+ badge.title = 'Exit chase cam (V)';
2300
+ } else if (selectedId) {
2301
+ badge.textContent = '📹 CHASE CAM';
2302
+ badge.classList.remove('active');
2303
+ badge.disabled = false;
2304
+ badge.title = 'Enter chase cam (V)';
2305
+ } else {
2306
+ badge.textContent = '📹 OVERVIEW';
2307
+ badge.classList.remove('active');
2308
+ badge.disabled = true;
2309
+ badge.title = 'Select an agent first';
2310
+ }
2311
+ }
2312
+
2313
  function exitFps() {
2314
  fpsMode = false;
2315
  fpsCameraPos = null;
2316
  fpsLookTarget = null;
2317
  document.getElementById('fpsHud').classList.remove('active');
2318
+ updateCameraBadge();
2319
  // Restore original orbit distances
2320
  if (app) {
2321
  app.controls.minDistance = 6;
 
2596
  function buildTraceForAgent(aid) {
2597
  const entries = traceHistory[aid] || [];
2598
  if (!entries.length) return '<div class="empty-state">No trace data yet</div>';
 
2599
  return entries.map(e => {
2600
+ if (e.phase !== 'exec') return '';
2601
+ const vis = pickVis(e.tool, e.args);
2602
+ const oc = outcomeChips(e.result);
2603
+ let extra = '';
2604
+ if (e.tool === 'move' || (e.tool === 'activate_ability' && e.args && e.args.ability === 'dash')) {
2605
+ const dx = e.args?.dx ?? 0, dy = e.args?.dy ?? 0;
2606
+ extra = `<span class="trace-arrow" style="color:${vis.color}">${ARROW_FOR(dx,dy)}</span>`;
2607
+ } else if (e.tool === 'activate_ability' && e.args && e.args.ability === 'attack' && (e.args.x!==undefined)) {
2608
+ extra = `<span class="trace-chip pos">→ (${e.args.x},${e.args.y})</span>`;
2609
+ } else if (e.tool === 'think') {
2610
+ const txt = String(e.args?.content || '').slice(0, 80);
2611
+ extra = txt ? `<span style="font-size:9.5px;color:var(--text-dim);font-style:italic;font-weight:400">${esc(txt)}${e.args?.content?.length>80?'…':''}</span>` : '';
2612
  }
2613
+ const result = String(e.result || '').slice(0, 120);
2614
+ return `<div class="trace-row ${oc.fail?'fail':''}" style="border-left-color:${vis.color}">
2615
+ <div class="trace-head">
2616
+ <span class="trace-icon">${vis.icon}</span>
2617
+ <span class="trace-tool" style="color:${vis.color}">${vis.label}</span>
2618
+ ${extra}
2619
+ ${oc.chips}
2620
+ <span class="trace-turn">t${e.turn}·r${e.round}</span>
2621
+ </div>
2622
+ ${result?`<div class="trace-result">${esc(result)}</div>`:''}
2623
+ </div>`;
2624
  }).join('');
2625
  }
2626
 
 
2672
  const name = agent?agent.name:aid, color=AGENT_COLORS[aid]||'#888';
2673
  const entries = log.agents[aid]||[];
2674
  entries.forEach(e => {
2675
+ if (e.phase!=='exec') return;
2676
+ const vis = pickVis(e.tool, e.args);
2677
+ const oc = outcomeChips(e.result);
2678
+ let extra = '';
2679
+ if (e.tool === 'move' || (e.tool === 'activate_ability' && e.args && e.args.ability === 'dash')) {
2680
+ extra = `<span class="trace-arrow" style="color:${vis.color}">${ARROW_FOR(e.args?.dx, e.args?.dy)}</span>`;
2681
  }
2682
+ html+=`<div class="trace-row ${oc.fail?'fail':''}" style="border-left-color:${color}">
2683
+ <div class="trace-head">
2684
+ <span class="trace-icon">${vis.icon}</span>
2685
+ <span style="color:${color};font-weight:700;font-size:10px">${esc(name)}</span>
2686
+ <span class="trace-tool" style="color:${vis.color}">${vis.label}</span>
2687
+ ${extra}
2688
+ ${oc.chips}
2689
+ </div>
2690
+ </div>`;
2691
  });
2692
  }
2693
  html += '</div>';
 
2720
  selectedId = id;
2721
  activeTab = 'trace';
2722
  buildSidebar();
2723
+ updateCameraBadge();
2724
  if (id && state) {
2725
  const agent = (state.agents||[]).find(a=>a.id===id);
2726
  if (agent && app) {
 
3073
  });
3074
 
3075
  window.addEventListener('resize', ()=>{ if (app) app.resize(); });
3076
+
3077
+ function returnToLobby() {
3078
+ if (autoRunning) auto();
3079
+ gameStarted = false; selectedId = null; state = null;
3080
+ document.getElementById('lobby').classList.remove('hidden');
3081
+ const g = document.getElementById('game'); g.classList.remove('visible'); g.style.display = 'none';
3082
+ }
3083
+
3084
+ async function bootProbe() {
3085
+ try {
3086
+ const r = await fetch('/api/game/state');
3087
+ if (!r.ok) return;
3088
+ const s = await r.json();
3089
+ if (!s || !s.agents || !s.agents.length) return;
3090
+ state = s;
3091
+ gameStarted = true;
3092
+ nameMaterialCache = {};
3093
+ traceHistory = {};
3094
+ document.getElementById('lobby').classList.add('hidden');
3095
+ const game = document.getElementById('game');
3096
+ game.classList.add('visible'); game.style.display = 'flex';
3097
+ showLoading('Joining live arena...');
3098
+ try {
3099
+ await loadThree();
3100
+ if (!app) initScene();
3101
+ requestAnimationFrame(() => { if (app) app.resize(); renderScene(); });
3102
+ updateUI();
3103
+ } finally { hideLoading(); }
3104
+ } catch (_) { /* keep lobby */ }
3105
+ }
3106
+ bootProbe();
3107
  </script>
3108
  </body>
3109
  </html>
main.py CHANGED
@@ -1,8 +1,17 @@
1
  import os
2
  import uvicorn
 
3
  from fastapi.staticfiles import StaticFiles
4
  from api import app
5
 
 
 
 
 
 
 
 
 
6
  app.mount("/", StaticFiles(directory="frontend/static", html=True), name="frontend")
7
 
8
  if __name__ == "__main__":
 
1
  import os
2
  import uvicorn
3
+ import gradio as gr
4
  from fastapi.staticfiles import StaticFiles
5
  from api import app
6
 
7
+ with gr.Blocks(title="Grid Royale — Gradio Panel", theme=gr.themes.Base()) as demo:
8
+ gr.Markdown(
9
+ "## ⚔️ Grid Royale\n"
10
+ "The main spectator UI is at the [root of this Space](/).\n"
11
+ "This panel exists for hackathon REQ-02 compliance and quick health checks."
12
+ )
13
+
14
+ app = gr.mount_gradio_app(app, demo, path="/gradio")
15
  app.mount("/", StaticFiles(directory="frontend/static", html=True), name="frontend")
16
 
17
  if __name__ == "__main__":
requirements.txt CHANGED
@@ -3,3 +3,4 @@ uvicorn>=0.20.0
3
  pydantic>=2.0
4
  openai>=1.0.0
5
  python-dotenv>=1.0.0
 
 
3
  pydantic>=2.0
4
  openai>=1.0.0
5
  python-dotenv>=1.0.0
6
+ gradio>=4.44.0