LokeshReddy001 commited on
Commit
bcad26c
·
0 Parent(s):

Initial commit: Grid Royale game backend and frontend.

Browse files
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ *.env
2
+ *.venv/
3
+ *pycache_*/
api.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from pydantic import BaseModel
4
+ from backend.environment import GameConfig
5
+ from backend.engine import Engine
6
+ from backend.tools import TOOL_SCHEMAS
7
+
8
+ app = FastAPI(title="Grid Royale API")
9
+
10
+ app.add_middleware(
11
+ CORSMiddleware,
12
+ allow_origins=["*"],
13
+ allow_methods=["*"],
14
+ allow_headers=["*"],
15
+ )
16
+
17
+ engine: Engine | None = None
18
+
19
+
20
+ class StartGameRequest(BaseModel):
21
+ grid_size: int = 20
22
+ max_turns: int = 50
23
+ max_agents: int = 8
24
+ num_chests: int = 15
25
+ agent_names: list[str] | None = None
26
+
27
+
28
+ def _serialize(aid: str) -> dict:
29
+ if not engine:
30
+ return {}
31
+ a = engine.env.agents[aid]
32
+ return {
33
+ "id": a.id,
34
+ "name": a.name,
35
+ "x": a.pos[0],
36
+ "y": a.pos[1],
37
+ "hp": a.hp,
38
+ "alive": a.alive,
39
+ "score": a.score,
40
+ "kills": a.kills,
41
+ "shielded": a.shielded,
42
+ "abilities": [
43
+ {"name": ab.name, "last_used_turn": ab.last_used_turn, "uses_remaining": ab.uses_remaining}
44
+ for ab in a.abilities
45
+ ],
46
+ "messages": a.messages[-24:],
47
+ }
48
+
49
+
50
+ def _game_state() -> dict:
51
+ if not engine:
52
+ return {}
53
+ alive = engine.env.alive_agents()
54
+ tiles = {}
55
+ for (x, y), tile in engine.env.grid.items():
56
+ tiles[f"{x},{y}"] = {
57
+ "terrain": tile.terrain,
58
+ "loot": tile.loot is not None,
59
+ }
60
+ return {
61
+ "turn": engine.env.turn,
62
+ "grid_size": engine.env.config.grid_size,
63
+ "max_turns": engine.env.config.max_turns,
64
+ "agents": [_serialize(aid) for aid in engine.env.agents],
65
+ "tiles": tiles,
66
+ "game_over": len(alive) <= 1 or engine.env.turn >= engine.env.config.max_turns,
67
+ "winner": alive[0].name if len(alive) == 1 else None,
68
+ "turn_log": engine.last_turn_log,
69
+ "tool_schemas": TOOL_SCHEMAS,
70
+ }
71
+
72
+
73
+ BOT_NAMES = [
74
+ "Vex", "Nyx", "Zara", "Kael",
75
+ "Rook", "Lyra", "Orin", "Sage",
76
+ ]
77
+
78
+
79
+ def _default_names(count: int) -> list[str]:
80
+ return BOT_NAMES[:count]
81
+
82
+
83
+ @app.post("/api/game/start")
84
+ async def start_game(req: StartGameRequest):
85
+ global engine
86
+ config = GameConfig(
87
+ grid_size=req.grid_size,
88
+ max_turns=req.max_turns,
89
+ max_agents=req.max_agents,
90
+ num_chests=req.num_chests,
91
+ )
92
+ engine = Engine(config=config)
93
+ engine.generate_grid()
94
+ engine.scatter_chests()
95
+
96
+ names = req.agent_names or _default_names(req.max_agents)
97
+ for i, name in enumerate(names):
98
+ if i >= req.max_agents:
99
+ break
100
+ engine.spawn_agent(agent_id=f"agent_{i}", name=name)
101
+
102
+ return {"message": f"Game started with {len(names)} agents", "turn": 0}
103
+
104
+
105
+ @app.post("/api/game/step")
106
+ async def game_step():
107
+ if not engine:
108
+ raise HTTPException(400, "No game running")
109
+ await engine.step()
110
+ return _game_state()
111
+
112
+
113
+ @app.get("/api/game/state")
114
+ async def game_state():
115
+ if not engine:
116
+ raise HTTPException(400, "No game running")
117
+ return _game_state()
118
+
119
+
120
+ @app.post("/api/game/run")
121
+ async def run_full_game(req: StartGameRequest):
122
+ global engine
123
+ config = GameConfig(
124
+ grid_size=req.grid_size,
125
+ max_turns=req.max_turns,
126
+ max_agents=req.max_agents,
127
+ num_chests=req.num_chests,
128
+ )
129
+ engine = Engine(config=config)
130
+ engine.generate_grid()
131
+ engine.scatter_chests()
132
+
133
+ names = req.agent_names or _default_names(req.max_agents)
134
+ for i, name in enumerate(names):
135
+ if i >= req.max_agents:
136
+ break
137
+ engine.spawn_agent(agent_id=f"agent_{i}", name=name)
138
+
139
+ await engine.run_game()
140
+ return _game_state()
backend/__init__.py ADDED
File without changes
backend/agent/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .state import AgentState, AbilityInstance
2
+
3
+ __all__ = ["AgentState", "AbilityInstance"]
backend/agent/agent.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import time
3
+ from openai import AsyncOpenAI
4
+
5
+ from .llm_client import get_llm_client
6
+
7
+
8
+ ToolCall = dict
9
+
10
+
11
+ class Agent:
12
+ def __init__(
13
+ self,
14
+ agent_id: str,
15
+ model: str = "google/gemma-4-26B-A4B-it",
16
+ provider: str = "modal",
17
+ system_prompt: str | None = None,
18
+ ):
19
+ self.id = agent_id
20
+ self.model = model
21
+ self.provider = provider
22
+ self.client: AsyncOpenAI = get_llm_client(provider)
23
+ self.system_prompt = system_prompt or (
24
+ "You are an AI agent in a grid-based battle royale game on a "
25
+ f"{'20x20' if not hasattr(self, 'grid_size') else str(self.grid_size)} grid. "
26
+ "Your goal: survive, collect loot, eliminate other agents, be the last one standing. "
27
+ "Use observe() to see your surroundings AND check your current HP, abilities, and cooldowns. "
28
+ "Abilities are found in loot chests scattered across the map — walk over a tile with loot to pick it up. "
29
+ "Once obtained, activate abilities via activate_ability(name, args). "
30
+ "Available abilities: attack(x,y), dash(dx,dy), shield(), heal(). "
31
+ "Coordinate your moves to maximize score and survival."
32
+ )
33
+
34
+ async def decide(self, messages: list, tools: list[dict]) -> list[ToolCall]:
35
+ import json
36
+ system_msg = {"role": "system", "content": self.system_prompt}
37
+ full_messages = [system_msg] + messages
38
+
39
+ t0 = time.time()
40
+ response = await self.client.chat.completions.create(
41
+ model=self.model,
42
+ messages=full_messages,
43
+ tools=tools,
44
+ tool_choice="auto",
45
+ )
46
+ elapsed = round((time.time() - t0) * 1000)
47
+
48
+ choice = response.choices[0]
49
+ if not choice.message.tool_calls:
50
+ return {"calls": [], "time_ms": elapsed, "raw": response.usage.model_dump() if response.usage else None}
51
+
52
+ results = []
53
+ for tc in choice.message.tool_calls:
54
+ try:
55
+ args = json.loads(tc.function.arguments)
56
+ except json.JSONDecodeError:
57
+ args = {}
58
+ results.append({"name": tc.function.name, "args": args})
59
+
60
+ return {
61
+ "calls": results,
62
+ "time_ms": elapsed,
63
+ "raw": response.usage.model_dump() if response.usage else None,
64
+ }
backend/agent/llm_client.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from openai import AsyncOpenAI
3
+ from dotenv import load_dotenv
4
+
5
+ load_dotenv()
6
+
7
+ PROVIDER_CONFIG = {
8
+ "local": {
9
+ "base_url": "http://localhost:11434/v1",
10
+ "api_key": "OLLAMA_API_KEY",
11
+ "models": [
12
+ "qwen2.5:7b",
13
+ "qwen2.5:3b",
14
+ "llama3.1:8b",
15
+ ],
16
+ },
17
+ "modal": {
18
+ "base_url": "https://lokeshreddypolu2004--example-vllm-inference-serve.modal.run/v1",
19
+ "api_key": "MODAL_API_KEY",
20
+ "models": [
21
+ "google/gemma-4-26B-A4B-it",
22
+ ],
23
+ },
24
+ "nvidia": {
25
+ "base_url": "https://integrate.api.nvidia.com/v1",
26
+ "api_key": "NVIDIA_NIM_API_KEY",
27
+ "models": [
28
+ "minimaxai/minimax-m2.7",
29
+ "stepfun-ai/step-3.5-flash",
30
+ "z-ai/glm4.7",
31
+ "deepseek-ai/deepseek-v3.2",
32
+ "moonshotai/kimi-k2-thinking",
33
+ "mistralai/devstral-2-123b-instruct-2512",
34
+ "mistralai/mistral-large-3-675b-instruct-2512",
35
+ "bytedance/seed-oss-36b-instruct",
36
+ "qwen/qwen3-coder-480b-a35b-instruct",
37
+ "nvidia/nemotron-3-ultra-550b-a55b",
38
+ ],
39
+ },
40
+ "openrouter": {
41
+ "base_url": "https://openrouter.ai/api/v1",
42
+ "api_key": "OPENROUTER_API_KEY",
43
+ "models": [
44
+ "moonshotai/kimi-k2.6",
45
+ "tencent/hy3-preview:free",
46
+ "inclusionai/ling-2.6-1t:free",
47
+ "inclusionai/ling-2.6-flash:free",
48
+ "google/gemma-4-26b-a4b-it:free",
49
+ "google/gemma-4-31b-it:free",
50
+ "nvidia/nemotron-3-super-120b-a12b:free",
51
+ "minimax/minimax-m2.5:free",
52
+ "liquid/lfm-2.5-1.2b-instruct:free",
53
+ "liquid/lfm-2.5-1.2b-thinking:free",
54
+ "nvidia/nemotron-3-nano-30b-a3b:free",
55
+ "nvidia/nemotron-nano-12b-v2-vl:free",
56
+ "qwen/qwen3-next-80b-a3b-instruct:free",
57
+ "openai/gpt-oss-120b:free",
58
+ "nvidia/nemotron-nano-9b-v2:free",
59
+ "openai/gpt-oss-20b:free",
60
+ "z-ai/glm-4.5-air:free",
61
+ "qwen/qwen3-coder:free",
62
+ ],
63
+ },
64
+ "groq": {
65
+ "base_url": "https://api.groq.com/openai/v1",
66
+ "api_key": "GROQ_API_KEY",
67
+ "models": [
68
+ "llama-3.1-8b-instant",
69
+ "llama-3.3-70b-versatile",
70
+ "openai/gpt-oss-120b",
71
+ "openai/gpt-oss-20b",
72
+ ],
73
+ },
74
+ "gemini": {
75
+ "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
76
+ "api_key": "GEMINI_API_KEY",
77
+ "models": [
78
+ "gemini-3-flash-preview",
79
+ "gemini-3.1-flash-lite-preview",
80
+ "gemini-2.5-pro",
81
+ "gemini-2.5-flash",
82
+ "gemini-2.5-flash-lite",
83
+ "gemma-4-31b-it",
84
+ "gemma-4-26b-a4b-it",
85
+ ],
86
+ },
87
+ "zai": {
88
+ "base_url": "https://api.z.ai/api/paas/v4/",
89
+ "api_key": "ZAI_API_KEY",
90
+ "models": [
91
+ "glm-4.7-flash",
92
+ "glm-4.6v-flash",
93
+ ],
94
+ },
95
+ }
96
+
97
+ DEFAULT_PROVIDER = "modal"
98
+
99
+
100
+ def get_llm_client(provider: str | None = None) -> AsyncOpenAI:
101
+ provider = provider or DEFAULT_PROVIDER
102
+ cfg = PROVIDER_CONFIG.get(provider) or PROVIDER_CONFIG[DEFAULT_PROVIDER]
103
+ api_key = os.getenv(cfg["api_key"]) or "ollama"
104
+ return AsyncOpenAI(base_url=cfg["base_url"], api_key=api_key)
backend/agent/state.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+
4
+
5
+ @dataclass
6
+ class AbilityInstance:
7
+ name: str
8
+ last_used_turn: int = -1
9
+ uses_remaining: int | None = None
10
+
11
+ def can_use(self, current_turn: int) -> bool:
12
+ if self.last_used_turn == current_turn:
13
+ return False
14
+ if self.uses_remaining is not None and self.uses_remaining <= 0:
15
+ return False
16
+ return True
17
+
18
+ def use(self, current_turn: int):
19
+ self.last_used_turn = current_turn
20
+ if self.uses_remaining is not None:
21
+ self.uses_remaining -= 1
22
+
23
+
24
+ @dataclass
25
+ class Message:
26
+ content: str
27
+ sender: str | None = None
28
+
29
+
30
+ @dataclass
31
+ class AgentState:
32
+ id: str
33
+ name: str
34
+ pos: list[int]
35
+ hp: int = 100
36
+ alive: bool = True
37
+ score: int = 0
38
+ kills: int = 0
39
+ shielded: bool = False
40
+ abilities: list[AbilityInstance] = field(default_factory=list)
41
+ messages: list[dict] = field(default_factory=list)
42
+ mailbox: list[Message] = field(default_factory=list)
43
+
44
+ def get_ability(self, name: str) -> AbilityInstance | None:
45
+ for ab in self.abilities:
46
+ if ab.name == name:
47
+ return ab
48
+ return None
49
+
50
+ def has_ability(self, name: str) -> bool:
51
+ return self.get_ability(name) is not None
backend/engine.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import asyncio
3
+ import json
4
+ import random
5
+ import time
6
+
7
+ from .environment import Environment, GameConfig
8
+ from .environment.tile import Tile
9
+ from .environment.loot_tables import generate_chest_loot
10
+ from .agent import AgentState
11
+ from .agent.agent import Agent
12
+ from .agent.state import AbilityInstance
13
+ from .tools import ALL_TOOLS, TOOL_SCHEMAS
14
+
15
+
16
+ class Engine:
17
+ def __init__(self, config: GameConfig | None = None):
18
+ self.env = Environment(config=config or GameConfig())
19
+ self.agents: dict[str, Agent] = {}
20
+ self.last_turn_log: dict | None = None
21
+
22
+ def generate_grid(self):
23
+ gs = self.env.config.grid_size
24
+ for x in range(gs):
25
+ for y in range(gs):
26
+ self.env.grid[(x, y)] = Tile(terrain="grass")
27
+
28
+ def scatter_chests(self, count: int | None = None):
29
+ count = count or self.env.config.num_chests
30
+ gs = self.env.config.grid_size
31
+ positions = random.sample(
32
+ [(x, y) for x in range(gs) for y in range(gs)],
33
+ count,
34
+ )
35
+ for pos in positions:
36
+ if self.env.is_empty(pos[0], pos[1]):
37
+ tile = self.env.get_tile(pos[0], pos[1])
38
+ tile.loot = generate_chest_loot()
39
+
40
+ def spawn_agent(
41
+ self,
42
+ agent_id: str,
43
+ name: str,
44
+ model: str | None = None,
45
+ provider: str | None = None,
46
+ system_prompt: str | None = None,
47
+ ):
48
+ gs = self.env.config.grid_size
49
+ while True:
50
+ x = random.randint(0, gs - 1)
51
+ y = random.randint(0, gs - 1)
52
+ if self.env.is_empty(x, y):
53
+ break
54
+
55
+ state = AgentState(
56
+ id=agent_id,
57
+ name=name,
58
+ pos=[x, y],
59
+ hp=self.env.config.base_hp,
60
+ )
61
+ brain = Agent(
62
+ agent_id=agent_id,
63
+ model=model or "google/gemma-4-26B-A4B-it",
64
+ provider=provider or "modal",
65
+ system_prompt=system_prompt,
66
+ )
67
+ state.messages.append({"role": "system", "content": brain.system_prompt})
68
+ self.env.agents[agent_id] = state
69
+ self.agents[agent_id] = brain
70
+ self._send_start_message(agent_id)
71
+ return state
72
+
73
+ async def step(self):
74
+ t0 = time.time()
75
+ alive = self.env.alive_agents()
76
+ if len(alive) <= 1:
77
+ if len(alive) == 1:
78
+ winner = alive[0]
79
+ winner.score += self.env.config.win_bonus
80
+ return
81
+
82
+ decisions: dict[str, dict] = {}
83
+ turn_log: dict[str, list[dict]] = {}
84
+
85
+ tasks = []
86
+ for aid, state in self.env.agents.items():
87
+ if not state.alive:
88
+ decisions[aid] = []
89
+ turn_log[aid] = []
90
+ continue
91
+ brain = self.agents.get(aid)
92
+ if brain:
93
+ tasks.append((aid, brain.decide(state.messages, TOOL_SCHEMAS)))
94
+ else:
95
+ decisions[aid] = []
96
+ turn_log[aid] = []
97
+
98
+ if tasks:
99
+ results = await asyncio.gather(*[t for _, t in tasks])
100
+ for (aid, _), result in zip(tasks, results):
101
+ calls = result["calls"]
102
+ decisions[aid] = calls
103
+ turn_log[aid] = [{
104
+ "phase": "llm",
105
+ "turn": self.env.turn,
106
+ "time_ms": result["time_ms"],
107
+ "usage": result.get("raw"),
108
+ }]
109
+ if calls:
110
+ state = self.env.agents[aid]
111
+ state.messages.append({
112
+ "role": "assistant",
113
+ "content": None,
114
+ "tool_calls": [
115
+ {"id": f"call_{i}", "type": "function",
116
+ "function": {"name": c["name"], "arguments": json.dumps(c.get("args", {}))}}
117
+ for i, c in enumerate(calls)
118
+ ],
119
+ })
120
+
121
+ for rnd in range(3):
122
+ for aid in list(self.env.agents.keys()):
123
+ state = self.env.agents[aid]
124
+ if not state.alive:
125
+ continue
126
+ calls = decisions.get(aid, [])
127
+ if rnd < len(calls):
128
+ call = calls[rnd]
129
+ exec_result = self.env.execute(aid, call["name"], call["args"])
130
+ state.messages.append({
131
+ "role": "tool",
132
+ "content": exec_result["text"],
133
+ "tool_call_id": f"call_{rnd}",
134
+ })
135
+ turn_log[aid].append({
136
+ "phase": "exec",
137
+ "round": rnd,
138
+ "tool": call["name"],
139
+ "args": call["args"],
140
+ "result": exec_result["text"],
141
+ "time_ms": exec_result["time_ms"],
142
+ })
143
+
144
+ self.env.turn += 1
145
+ step_total = round((time.time() - t0) * 1000)
146
+
147
+ for state in self.env.agents.values():
148
+ if state.alive:
149
+ state.score += self.env.config.survival_score_per_turn
150
+
151
+ if self.env.turn % self.env.config.chest_respawn_interval == 0:
152
+ self.scatter_chests(count=max(1, self.env.config.num_chests // 3))
153
+
154
+ self.last_turn_log = {
155
+ "time_ms": step_total,
156
+ "agents": turn_log,
157
+ }
158
+
159
+ def _send_start_message(self, aid: str):
160
+ agent = self.env.agents[aid]
161
+ agent.messages.append({
162
+ "role": "user",
163
+ "content": (
164
+ f"You are {agent.name}. You have been dropped into a battle royale on a "
165
+ f"{self.env.config.grid_size}x{self.env.config.grid_size} grid. "
166
+ f"Your HP is {agent.hp}. There are {len(self.env.alive_agents())} agents alive. "
167
+ "Use observe() to see your surroundings, move() to explore, "
168
+ "and activate_ability() once you find abilities in loot chests. "
169
+ "Good luck."
170
+ ),
171
+ })
172
+
173
+ async def run_game(self) -> dict[str, AgentState]:
174
+ self.generate_grid()
175
+ self.scatter_chests()
176
+
177
+ while self.env.turn < self.env.config.max_turns:
178
+ alive = self.env.alive_agents()
179
+ if len(alive) <= 1:
180
+ break
181
+ await self.step()
182
+
183
+ if len(self.env.alive_agents()) == 1:
184
+ winner = self.env.alive_agents()[0]
185
+ winner.score += self.env.config.win_bonus
186
+
187
+ return self.env.agents
backend/environment/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .config import GameConfig
2
+ from .tile import Tile, LootItem
3
+ from .environment import Environment
4
+ from .ability_registry import ABILITY_REGISTRY, AbilityDef
5
+ from .scoring import calculate_score
6
+ from .loot_tables import generate_loot, generate_chest_loot, generate_death_loot
7
+
8
+ __all__ = [
9
+ "GameConfig",
10
+ "Tile",
11
+ "LootItem",
12
+ "Environment",
13
+ "ABILITY_REGISTRY",
14
+ "AbilityDef",
15
+ "calculate_score",
16
+ "generate_loot",
17
+ "generate_chest_loot",
18
+ "generate_death_loot",
19
+ ]
backend/environment/ability_registry.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from typing import Callable, TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from .environment import Environment
7
+
8
+
9
+ @dataclass
10
+ class AbilityDef:
11
+ name: str
12
+ cooldown: int
13
+ max_uses: int | None
14
+ description: str
15
+ handler: Callable
16
+
17
+
18
+ def attack_handler(env: Environment, aid: str, args: dict) -> str:
19
+ agent = env.agents[aid]
20
+ x, y = agent.pos
21
+ tx = args.get("x", args.get("target_x"))
22
+ ty = args.get("y", args.get("target_y"))
23
+
24
+ if tx is None or ty is None:
25
+ return "Attack requires target coordinates (x, y)"
26
+
27
+ dist = abs(tx - x) + abs(ty - y)
28
+ if dist > env.config.attack_range:
29
+ return f"Target at ({tx},{ty}) is out of attack range (max {env.config.attack_range})"
30
+
31
+ target = None
32
+ for other in env.agents.values():
33
+ if other.pos == [tx, ty] and other.alive:
34
+ target = other
35
+ break
36
+
37
+ if not target:
38
+ return f"No agent at ({tx},{ty}) to attack"
39
+
40
+ if target.shielded:
41
+ target.shielded = False
42
+ return f"{target.name} blocked your attack with a shield!"
43
+
44
+ damage = env.config.attack_damage
45
+ target.hp -= damage
46
+
47
+ if target.hp <= 0:
48
+ target.alive = False
49
+ target.hp = 0
50
+ agent.score += env.config.kill_score
51
+ agent.kills += 1
52
+
53
+ from .loot_tables import generate_death_loot
54
+ tile = env.get_tile(tx, ty)
55
+ tile.loot = generate_death_loot(target)
56
+
57
+ return f"Attacked {target.name} for {damage} damage — ELIMINATED! +{env.config.kill_score} points"
58
+ else:
59
+ return f"Attacked {target.name} for {damage} damage (HP left: {target.hp})"
60
+
61
+
62
+ def dash_handler(env: Environment, aid: str, args: dict) -> str:
63
+ agent = env.agents[aid]
64
+ x, y = agent.pos
65
+ dx = args.get("dx", 0)
66
+ dy = args.get("dy", 0)
67
+
68
+ if abs(dx) > 3 or abs(dy) > 3:
69
+ return "Dash max distance is 3 tiles"
70
+
71
+ nx, ny = x + dx, y + dy
72
+
73
+ if not env.is_in_bounds(nx, ny):
74
+ return "Cannot dash out of bounds"
75
+
76
+ if not env.is_empty(nx, ny):
77
+ return "Cannot dash into an occupied tile"
78
+
79
+ agent.pos = [nx, ny]
80
+ return f"Dashed from ({x},{y}) to ({nx},{ny})"
81
+
82
+
83
+ def shield_handler(env: Environment, aid: str, args: dict) -> str:
84
+ agent = env.agents[aid]
85
+ agent.shielded = True
86
+ return "Shield activated — you will block the next attack against you"
87
+
88
+
89
+ def heal_handler(env: Environment, aid: str, args: dict) -> str:
90
+ agent = env.agents[aid]
91
+ heal_amount = 40
92
+ old_hp = agent.hp
93
+ agent.hp = min(agent.hp + heal_amount, env.config.base_hp)
94
+ actual = agent.hp - old_hp
95
+ return f"Healed for {actual} HP (HP: {old_hp} -> {agent.hp})"
96
+
97
+
98
+ ABILITY_REGISTRY: dict[str, AbilityDef] = {
99
+ "attack": AbilityDef(
100
+ name="attack",
101
+ cooldown=1,
102
+ max_uses=None,
103
+ description="Attack an adjacent agent for 25 damage",
104
+ handler=attack_handler,
105
+ ),
106
+ "dash": AbilityDef(
107
+ name="dash",
108
+ cooldown=2,
109
+ max_uses=3,
110
+ description="Teleport up to 3 tiles in a direction",
111
+ handler=dash_handler,
112
+ ),
113
+ "shield": AbilityDef(
114
+ name="shield",
115
+ cooldown=3,
116
+ max_uses=None,
117
+ description="Become immune to the next attack",
118
+ handler=shield_handler,
119
+ ),
120
+ "heal": AbilityDef(
121
+ name="heal",
122
+ cooldown=2,
123
+ max_uses=2,
124
+ description="Restore 40 HP",
125
+ handler=heal_handler,
126
+ ),
127
+ }
backend/environment/config.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass, field
2
+ from typing import Literal
3
+
4
+
5
+ @dataclass
6
+ class GameConfig:
7
+ mode: Literal["battle_royale"] = "battle_royale"
8
+ grid_size: int = 20
9
+ max_turns: int = 50
10
+ max_agents: int = 8
11
+ base_hp: int = 100
12
+ attack_damage: int = 25
13
+ attack_range: int = 1
14
+ num_chests: int = 15
15
+ chest_respawn_interval: int = 5
16
+ chest_value_range: tuple = (10, 50)
17
+ kill_score: int = 50
18
+ survival_score_per_turn: int = 5
19
+ win_bonus: int = 100
20
+ observation_radius: int = 5
backend/environment/environment.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import time
3
+ from dataclasses import dataclass, field
4
+
5
+ from .config import GameConfig
6
+ from .tile import Tile
7
+ from ..agent.state import AgentState
8
+
9
+
10
+ @dataclass
11
+ class Environment:
12
+ grid: dict[tuple[int, int], Tile] = field(default_factory=dict)
13
+ agents: dict[str, AgentState] = field(default_factory=dict)
14
+ turn: int = 0
15
+ config: GameConfig = field(default_factory=GameConfig)
16
+
17
+ def get_tile(self, x: int, y: int) -> Tile:
18
+ return self.grid.get((x, y), Tile(terrain="void"))
19
+
20
+ def is_in_bounds(self, x: int, y: int) -> bool:
21
+ return 0 <= x < self.config.grid_size and 0 <= y < self.config.grid_size
22
+
23
+ def is_empty(self, x: int, y: int) -> bool:
24
+ return self.is_in_bounds(x, y) and not any(
25
+ a.pos == [x, y] and a.alive for a in self.agents.values()
26
+ )
27
+
28
+ def alive_agents(self) -> list[AgentState]:
29
+ return [a for a in self.agents.values() if a.alive]
30
+
31
+ def get_ability_cooldown(self, name: str) -> int:
32
+ from .ability_registry import ABILITY_REGISTRY
33
+
34
+ ab = ABILITY_REGISTRY.get(name)
35
+ return ab.cooldown if ab else 0
36
+
37
+ def get_ability_description(self, name: str) -> str:
38
+ from .ability_registry import ABILITY_REGISTRY
39
+
40
+ ab = ABILITY_REGISTRY.get(name)
41
+ return ab.description if ab else ""
42
+
43
+ def execute(self, aid: str, tool_name: str, args: dict) -> dict:
44
+ from ..tools import ALL_TOOLS
45
+
46
+ t0 = time.time()
47
+ tool_cls = ALL_TOOLS.get(tool_name)
48
+ if not tool_cls:
49
+ elapsed = round((time.time() - t0) * 1000)
50
+ return {"text": f"Unknown tool: {tool_name}", "time_ms": elapsed}
51
+ text = tool_cls.run(self, aid, args)
52
+ elapsed = round((time.time() - t0) * 1000)
53
+ return {"text": text, "time_ms": elapsed}
backend/environment/loot_tables.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import random
3
+ from typing import TYPE_CHECKING
4
+
5
+ from .tile import LootItem
6
+
7
+ if TYPE_CHECKING:
8
+ from ..agent.state import AgentState
9
+
10
+
11
+ AVAILABLE_ABILITIES = ["attack", "dash", "shield", "heal"]
12
+
13
+ RARITY_WEIGHTS = {
14
+ "common": 0.6,
15
+ "rare": 0.3,
16
+ "epic": 0.1,
17
+ }
18
+
19
+ ABILITY_RARITY_POOL = {
20
+ "common": ["dash", "heal"],
21
+ "rare": ["dash", "shield", "heal"],
22
+ "epic": ["attack", "shield"],
23
+ }
24
+
25
+ POINTS_BY_RARITY = {
26
+ "common": (10, 20),
27
+ "rare": (25, 40),
28
+ "epic": (50, 100),
29
+ }
30
+
31
+ NUM_ITEMS_PER_CHEST = {
32
+ "common": (1, 1),
33
+ "rare": (1, 2),
34
+ "epic": (2, 3),
35
+ }
36
+
37
+
38
+ def _pick_rarity() -> str:
39
+ r = random.random()
40
+ cumulative = 0
41
+ for rarity, weight in RARITY_WEIGHTS.items():
42
+ cumulative += weight
43
+ if r <= cumulative:
44
+ return rarity
45
+ return "common"
46
+
47
+
48
+ def _pick_ability(rarity: str) -> str | None:
49
+ pool = ABILITY_RARITY_POOL.get(rarity, [])
50
+ if not pool:
51
+ return None
52
+ return random.choice(pool)
53
+
54
+
55
+ def generate_chest_loot() -> list[LootItem]:
56
+ rarity = _pick_rarity()
57
+ num_items = random.randint(*NUM_ITEMS_PER_CHEST[rarity])
58
+ items = []
59
+
60
+ for _ in range(num_items):
61
+ if random.random() < 0.5:
62
+ ability = _pick_ability(rarity)
63
+ if ability:
64
+ items.append(LootItem(
65
+ type="ability",
66
+ ability_name=ability,
67
+ rarity=rarity,
68
+ source="chest",
69
+ ))
70
+ else:
71
+ pts = random.randint(*POINTS_BY_RARITY[rarity])
72
+ items.append(LootItem(
73
+ type="points",
74
+ points=pts,
75
+ rarity=rarity,
76
+ source="chest",
77
+ ))
78
+ else:
79
+ pts = random.randint(*POINTS_BY_RARITY[rarity])
80
+ items.append(LootItem(
81
+ type="points",
82
+ points=pts,
83
+ rarity=rarity,
84
+ source="chest",
85
+ ))
86
+
87
+ return items
88
+
89
+
90
+ def generate_death_loot(agent: AgentState) -> list[LootItem]:
91
+ items = []
92
+
93
+ transferrable = [ab for ab in agent.abilities if ab.name in AVAILABLE_ABILITIES]
94
+ if transferrable:
95
+ num_to_drop = min(len(transferrable), random.randint(1, 2))
96
+ dropped = random.sample(transferrable, num_to_drop)
97
+ for ab in dropped:
98
+ items.append(LootItem(
99
+ type="ability",
100
+ ability_name=ab.name,
101
+ rarity="common",
102
+ source=agent.id,
103
+ ))
104
+
105
+ dropped_points = agent.score // 2
106
+ if dropped_points > 0:
107
+ items.append(LootItem(
108
+ type="points",
109
+ points=dropped_points,
110
+ rarity="common",
111
+ source=agent.id,
112
+ ))
113
+
114
+ return items
115
+
116
+
117
+ def generate_loot(count: int) -> list[list[LootItem]]:
118
+ return [generate_chest_loot() for _ in range(count)]
backend/environment/scoring.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ from .environment import Environment
6
+
7
+
8
+ def calculate_score(env: Environment) -> dict[str, int]:
9
+ scores = {}
10
+ for aid, agent in env.agents.items():
11
+ scores[aid] = agent.score
12
+ return scores
13
+
14
+
15
+ def rank_agents(env: Environment) -> list[tuple[str, int]]:
16
+ scored = [(aid, agent.score) for aid, agent in env.agents.items()]
17
+ scored.sort(key=lambda x: x[1], reverse=True)
18
+ return scored
backend/environment/tile.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from typing import Literal
4
+
5
+
6
+ @dataclass
7
+ class LootItem:
8
+ type: Literal["ability", "points"]
9
+ ability_name: str | None = None
10
+ points: int | None = None
11
+ rarity: str = "common"
12
+ source: str = "chest"
13
+
14
+
15
+ @dataclass
16
+ class Tile:
17
+ terrain: str = "grass"
18
+ loot: list[LootItem] | None = None
backend/tools/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .move import MoveTool
2
+ from .think import ThinkTool
3
+ from .observe import ObserveTool
4
+ from .activate_ability import ActivateAbilityTool
5
+
6
+ ALL_TOOLS: dict[str, type] = {
7
+ t.name: t
8
+ for t in [MoveTool, ThinkTool, ObserveTool, ActivateAbilityTool]
9
+ }
10
+ TOOL_SCHEMAS: list[dict] = [t.schema for t in ALL_TOOLS.values()]
11
+
12
+ __all__ = [
13
+ "MoveTool",
14
+ "ThinkTool",
15
+ "ObserveTool",
16
+ "ActivateAbilityTool",
17
+ "ALL_TOOLS",
18
+ "TOOL_SCHEMAS",
19
+ ]
backend/tools/activate_ability.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ from ..environment.environment import Environment
6
+
7
+
8
+ class ActivateAbilityTool:
9
+ name = "activate_ability"
10
+ schema = {
11
+ "type": "function",
12
+ "function": {
13
+ "name": "activate_ability",
14
+ "description": "Activate one of your acquired abilities. Each ability has different effects and args.",
15
+ "parameters": {
16
+ "type": "object",
17
+ "properties": {
18
+ "ability": {
19
+ "type": "string",
20
+ "description": "Name of the ability to activate",
21
+ },
22
+ "args": {
23
+ "type": "object",
24
+ "description": "Arguments for the ability (varies per ability)",
25
+ },
26
+ },
27
+ "required": ["ability", "args"],
28
+ },
29
+ },
30
+ }
31
+
32
+ @staticmethod
33
+ def run(env: Environment, aid: str, args: dict) -> str:
34
+ ability_name = args.get("ability", "")
35
+ ability_args = args.get("args", {})
36
+
37
+ agent = env.agents[aid]
38
+ ability = agent.get_ability(ability_name)
39
+ if not ability:
40
+ return f"You don't have the ability '{ability_name}'"
41
+
42
+ if not ability.can_use(env.turn):
43
+ return f"Ability '{ability_name}' cannot be used right now (cooldown or no uses left)"
44
+
45
+ from ..environment.ability_registry import ABILITY_REGISTRY
46
+
47
+ ability_def = ABILITY_REGISTRY.get(ability_name)
48
+ if not ability_def:
49
+ return f"Unknown ability: {ability_name}"
50
+
51
+ result = ability_def.handler(env, aid, ability_args)
52
+ ability.use(env.turn)
53
+ return result
backend/tools/move.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ from ..environment.environment import Environment
6
+
7
+
8
+ class MoveTool:
9
+ name = "move"
10
+ schema = {
11
+ "type": "function",
12
+ "function": {
13
+ "name": "move",
14
+ "description": "Move to an adjacent tile. Auto-loots any items on the destination tile.",
15
+ "parameters": {
16
+ "type": "object",
17
+ "properties": {
18
+ "dx": {"type": "integer", "description": "Change in x (-1, 0, or 1)"},
19
+ "dy": {"type": "integer", "description": "Change in y (-1, 0, or 1)"},
20
+ },
21
+ "required": ["dx", "dy"],
22
+ },
23
+ },
24
+ }
25
+
26
+ @staticmethod
27
+ def run(env: Environment, aid: str, args: dict) -> str:
28
+ agent = env.agents[aid]
29
+ x, y = agent.pos
30
+ dx, dy = args.get("dx", 0), args.get("dy", 0)
31
+
32
+ if abs(dx) > 1 or abs(dy) > 1:
33
+ return "Invalid move: can only move to adjacent tiles (±1 in x or y)"
34
+
35
+ nx, ny = x + dx, y + dy
36
+
37
+ if not env.is_in_bounds(nx, ny):
38
+ return "Invalid move: out of bounds"
39
+
40
+ if not env.is_empty(nx, ny):
41
+ return "Invalid move: tile is occupied"
42
+
43
+ agent.pos = [nx, ny]
44
+ tile = env.get_tile(nx, ny)
45
+
46
+ loot_results = []
47
+ if tile.loot:
48
+ for item in tile.loot:
49
+ if item.type == "ability":
50
+ from ..agent.state import AbilityInstance
51
+ from ..environment.ability_registry import ABILITY_REGISTRY
52
+ agent.abilities.append(
53
+ AbilityInstance(name=item.ability_name)
54
+ )
55
+ desc = env.get_ability_description(item.ability_name)
56
+ cd = env.get_ability_cooldown(item.ability_name)
57
+ ab_def = ABILITY_REGISTRY.get(item.ability_name)
58
+ uses_str = "unlimited" if ab_def and ab_def.max_uses is None else (f"{ab_def.max_uses} use(s)" if ab_def else "?")
59
+ loot_results.append(
60
+ f"picked up ability '{item.ability_name}': {desc} "
61
+ f"(cooldown {cd} turn(s), {uses_str})"
62
+ )
63
+ elif item.type == "points":
64
+ agent.score += item.points or 0
65
+ loot_results.append(f"picked up {item.points} points")
66
+ tile.loot = None
67
+
68
+ msg = f"Moved to ({nx}, {ny})"
69
+ if loot_results:
70
+ msg += ". Loot: " + ", ".join(loot_results)
71
+ return msg
backend/tools/observe.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ from ..environment.environment import Environment
6
+
7
+
8
+ class ObserveTool:
9
+ name = "observe"
10
+ schema = {
11
+ "type": "function",
12
+ "function": {
13
+ "name": "observe",
14
+ "description": "Observe your surroundings. Returns visible tiles and agents within your observation radius.",
15
+ "parameters": {
16
+ "type": "object",
17
+ "properties": {},
18
+ },
19
+ },
20
+ }
21
+
22
+ @staticmethod
23
+ def run(env: Environment, aid: str, args: dict) -> str:
24
+ agent = env.agents[aid]
25
+ x, y = agent.pos
26
+ radius = env.config.observation_radius
27
+
28
+ lines = []
29
+ lines.append(f"=== Status ===")
30
+ lines.append(f"HP: {agent.hp}/{env.config.base_hp} | Score: {agent.score} | Kills: {agent.kills}")
31
+ lines.append(f"Shielded: {agent.shielded}")
32
+ if agent.abilities:
33
+ lines.append("Abilities:")
34
+ for ab in agent.abilities:
35
+ cd = env.get_ability_cooldown(ab.name)
36
+ if ab.last_used_turn >= 0 and env.turn < ab.last_used_turn + cd:
37
+ cd_str = f"cooldown {ab.last_used_turn + cd - env.turn} turn(s)"
38
+ else:
39
+ cd_str = "ready"
40
+ uses_str = f"unlimited" if ab.uses_remaining is None else f"{ab.uses_remaining} use(s)"
41
+ desc = env.get_ability_description(ab.name)
42
+ lines.append(f" [{ab.name}] {desc}")
43
+ lines.append(f" uses: {uses_str} | cooldown: {cd_str}")
44
+ else:
45
+ lines.append("Abilities: none")
46
+ lines.append("")
47
+
48
+ visible_tiles = []
49
+ for dx in range(-radius, radius + 1):
50
+ for dy in range(-radius, radius + 1):
51
+ nx, ny = x + dx, y + dy
52
+ if not env.is_in_bounds(nx, ny):
53
+ continue
54
+ dist = abs(dx) + abs(dy)
55
+ if dist > radius:
56
+ continue
57
+ tile = env.get_tile(nx, ny)
58
+ entry = f"({nx},{ny}): {tile.terrain}"
59
+ if tile.loot:
60
+ entry += " [has loot]"
61
+ if dist <= 1:
62
+ visible_tiles.append(entry)
63
+
64
+ visible_agents = []
65
+ for other in env.agents.values():
66
+ if other.id == aid or not other.alive:
67
+ continue
68
+ ox, oy = other.pos
69
+ dist = abs(ox - x) + abs(oy - y)
70
+ if dist <= radius:
71
+ visible_agents.append(
72
+ f"{other.name} at ({ox},{oy}) [HP:{other.hp}]"
73
+ )
74
+
75
+ lines.append("--- Nearby tiles ---")
76
+ lines.extend(visible_tiles)
77
+ lines.append("--- Visible agents ---")
78
+ if visible_agents:
79
+ lines.extend(visible_agents)
80
+ else:
81
+ lines.append("No agents visible")
82
+
83
+ return "\n".join(lines)
backend/tools/think.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ from ..environment.environment import Environment
6
+
7
+
8
+ class ThinkTool:
9
+ name = "think"
10
+ schema = {
11
+ "type": "function",
12
+ "function": {
13
+ "name": "think",
14
+ "description": "Internal monologue. Use this to reason about your strategy without any external effect.",
15
+ "parameters": {
16
+ "type": "object",
17
+ "properties": {
18
+ "content": {
19
+ "type": "string",
20
+ "description": "Your internal thoughts and reasoning",
21
+ },
22
+ },
23
+ "required": ["content"],
24
+ },
25
+ },
26
+ }
27
+
28
+ @staticmethod
29
+ def run(env: Environment, aid: str, args: dict) -> str:
30
+ content = args.get("content", "")
31
+ return "Thought Acknowledged"
design.md ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Grid Royale — Design Doc
2
+
3
+ ## Game Mode: Battle Royale (MVP)
4
+
5
+ 8 agents drop onto a 20×20 grid. Last agent standing wins. Loot chests
6
+ contain abilities and points. Killing an agent drops loot — pick up
7
+ their leftover abilities and score.
8
+
9
+ ---
10
+
11
+ ## Core Abstraction
12
+
13
+ ### Environment — single source of truth for ALL state
14
+
15
+ ```python
16
+ @dataclass
17
+ class Environment:
18
+ grid: dict[tuple[int, int], Tile]
19
+ agents: dict[str, AgentState]
20
+ turn: int
21
+ config: GameConfig
22
+ ```
23
+
24
+ ### Tile — lightweight, holds terrain + optional loot
25
+
26
+ ```python
27
+ @dataclass
28
+ class LootItem:
29
+ type: Literal["ability", "points"]
30
+ ability_name: str | None = None
31
+ points: int | None = None
32
+ rarity: str = "common"
33
+ source: str = "chest" # "chest" or the agent_id who dropped it
34
+
35
+ @dataclass
36
+ class Tile:
37
+ terrain: str
38
+ loot: list[LootItem] | None = None
39
+ ```
40
+
41
+ No wrapper classes. No separate `DeathCache` / `Chest`. Just a list of `LootItem` on the tile — whether it came from a chest or a dead agent.
42
+
43
+ ### Game Config
44
+
45
+ ```python
46
+ @dataclass
47
+ class GameConfig:
48
+ mode: Literal["battle_royale"] = "battle_royale"
49
+ grid_size: int = 20
50
+ max_turns: int = 50
51
+ max_agents: int = 8
52
+ base_hp: int = 100
53
+ attack_damage: int = 25
54
+ attack_range: int = 1
55
+ num_chests: int = 15
56
+ chest_respawn_interval: int = 5
57
+ chest_value_range: tuple = (10, 50)
58
+ kill_score: int = 50
59
+ survival_score_per_turn: int = 5
60
+ win_bonus: int = 100
61
+ ```
62
+
63
+ ### Agent State
64
+
65
+ ```python
66
+ @dataclass
67
+ class AgentState:
68
+ id: str
69
+ name: str
70
+ pos: list[int]
71
+ hp: int
72
+ alive: bool
73
+ score: int
74
+ kills: int
75
+ shielded: bool # passive buff from shield ability
76
+ abilities: list[AbilityInstance] # acquired abilities with runtime state
77
+ messages: list[dict]
78
+ mailbox: list[Message]
79
+
80
+ @dataclass
81
+ class AbilityInstance:
82
+ name: str
83
+ last_used_turn: int = -1 # for cooldown
84
+ uses_remaining: int | None = None # None = infinite
85
+ ```
86
+
87
+ ### Agent — stateless brain (owns LLM client)
88
+
89
+ ```python
90
+ class Agent:
91
+ id: str
92
+ model: str
93
+ provider: str
94
+ client: AsyncOpenAI
95
+
96
+ async def decide(self, messages: list, tools: list) -> list[ToolCall]:
97
+ # one LLM call
98
+ ```
99
+
100
+ The brain is ephemeral — owns how the agent thinks (model, provider, client).
101
+ The state of what the agent knows (messages, position, HP, abilities) lives
102
+ in `AgentState` inside `Environment`.
103
+
104
+ ### Tools — static schemas, shared by all agents
105
+
106
+ Exactly 4 tools. Never changes. No cache misses.
107
+
108
+ | Tool | Effect |
109
+ |---|---|
110
+ | `move(dx, dy)` | Move to adjacent tile. Auto-loots loot on tile if present. |
111
+ | `observe()` | Return visible tiles + agents within observation radius. |
112
+ | `think(content)` | Internal monologue — appended to conversation, no side effect. |
113
+ | `activate_ability(name, args)` | Dispatch to ability handler. Checks cooldown + remaining uses. |
114
+
115
+ `activate_ability` takes a flexible `args: object` that each handler
116
+ interprets differently. Example abilities:
117
+
118
+ ```python
119
+ # attack — needs a position target
120
+ activate_ability(name="attack", args={"x": 5, "y": 7})
121
+
122
+ # shield — self-cast, no args
123
+ activate_ability(name="shield", args={})
124
+
125
+ # swap (hypothetical rare) — needs a target agent ID
126
+ activate_ability(name="swap", args={"target_id": "agent_3"})
127
+ ```
128
+
129
+ ### Abilities — registered internally, not as separate tools
130
+
131
+ ```python
132
+ ABILITY_REGISTRY = {
133
+ "attack": {"cooldown": 1, "uses": None, "handler": attack_handler},
134
+ "dash": {"cooldown": 2, "uses": 3, "handler": dash_handler},
135
+ "shield": {"cooldown": 3, "uses": None, "handler": shield_handler},
136
+ "heal": {"cooldown": 2, "uses": 2, "handler": heal_handler},
137
+ }
138
+ ```
139
+
140
+ `activate_ability` checks: does agent have this ability? Can it be used
141
+ this turn (cooldown + remaining uses)? Then dispatches to the handler.
142
+
143
+ ### Scoring
144
+
145
+ | Event | Points |
146
+ |---|---|
147
+ | Kill | +50 |
148
+ | Per turn alive | +5 |
149
+ | Loot collected | 10–100 (by rarity) |
150
+ | Win (last alive) | +100 |
151
+
152
+ ### Engine — orchestrates the turn loop
153
+
154
+ ```python
155
+ class Engine:
156
+ env: Environment
157
+ agents: dict[str, Agent]
158
+
159
+ async def step(self):
160
+ # Phase 1: all agents decide (parallel)
161
+ # Phase 2: execute up to 3 tool calls round-robin
162
+ # Post-turn: check eliminations → drop loot, respawn chests
163
+ self.env.turn += 1
164
+ ```
165
+
166
+ Elimination flow:
167
+ 1. `attack_handler` reduces HP → if HP ≤ 0, mark `alive=False`
168
+ 2. Place `LootItem` entries on their tile (1-2 random abilities + 50% score,
169
+ `source = dead_agent_id`)
170
+ 3. Credit killer with +50 score, increment kills
171
+
172
+ Respawn (not in BR — only for hypothetical DM mode later): revived agents
173
+ start at random empty tile with base HP and **no abilities**.
174
+
175
+ ---
176
+
177
+ ## File Layout
178
+
179
+ ```
180
+ grid-royale/
181
+ backend/
182
+ environment/
183
+ __init__.py
184
+ environment.py # Environment dataclass
185
+ tile.py # Tile, LootItem
186
+ config.py # GameConfig
187
+ ability_registry.py # ABILITY_REGISTRY + handlers
188
+ scoring.py # Score logic
189
+ loot_tables.py # Loot generation
190
+ agent/
191
+ __init__.py
192
+ state.py # AgentState, AbilityInstance
193
+ agent.py # Agent brain: decide(messages, tools)
194
+ llm_client.py # Provider configs + factory
195
+ tools/
196
+ __init__.py # ALL_TOOLS, TOOL_SCHEMAS (4 tools)
197
+ move.py
198
+ think.py
199
+ observe.py
200
+ activate_ability.py
201
+ engine.py # Engine (turn loop + eliminations + scoring)
202
+ api.py # FastAPI routes
203
+ main.py # uvicorn entry point
204
+ frontend/
205
+ dashboard.py # Gradio app — grid view, game log, agent cards
206
+
207
+ No `static/index.html`. Gradio serves the UI server-side (Python-native,
208
+ fast for hackathon). Frontend is a thin visualization layer on top of the
209
+ API — reads `env` snapshot and renders it.
210
+
211
+ ## Key Design Properties
212
+
213
+ | Concept | Lives where | Mutated by |
214
+ |---|---|---|
215
+ | Grid + terrain | `env.grid` | tools |
216
+ | Loot on tiles | `env.grid[x,y].loot` | engine (spawn), MoveTool (pickup) |
217
+ | Agent HP, score, abilities | `env.agents[aid]` | tools |
218
+ | LLM conversation | `env.agents[aid].messages` | agent.decide(), tools |
219
+ | Tool schemas | `tools/__init__.py` | never (static, 4 tools) |
220
+ | Ability logic | `environment/ability_registry.py` | handlers |
221
+ | Scoring logic | `environment/scoring.py` | engine |
222
+ | Loot tables | `environment/loot_tables.py` | engine |
frontend/static/index.html ADDED
@@ -0,0 +1,936 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
6
+ <title>Grid Royale</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@300;400;500;600&family=Geist+Mono:wght@400;500&display=swap">
10
+ <script type="importmap">
11
+ {
12
+ "imports": {
13
+ "three": "https://unpkg.com/three@0.160.0/build/three.module.js"
14
+ }
15
+ }
16
+ </script>
17
+ <style>
18
+ :root {
19
+ --bg: #0c0a08;
20
+ --surface: #14110f;
21
+ --border: #231f1c;
22
+ --border2: #1a1714;
23
+ --faint: #5a5048;
24
+ --dim: #7a6f63;
25
+ --muted: #9a8f81;
26
+ --cream: #f4ecde;
27
+ --accent: #e74c3c;
28
+ --accent-dim: rgba(231, 76, 60, 0.15);
29
+ }
30
+ * { box-sizing: border-box; }
31
+ html, body { margin: 0; padding: 0; height: 100%; }
32
+ body {
33
+ background: var(--bg);
34
+ color: var(--cream);
35
+ font-family: 'Geist', system-ui, sans-serif;
36
+ -webkit-font-smoothing: antialiased;
37
+ overflow: hidden;
38
+ }
39
+ .serif { font-family: 'Instrument Serif', serif; }
40
+ .mono { font-family: 'Geist Mono', monospace; }
41
+
42
+ /* ── Lobby ── */
43
+ #lobby {
44
+ position: fixed; inset: 0; z-index: 100;
45
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
46
+ gap: 2rem; padding: 2rem;
47
+ background: radial-gradient(ellipse 60% 50% at 50% 40%, #14100c, var(--bg) 70%);
48
+ transition: opacity 0.6s ease, transform 0.6s ease;
49
+ }
50
+ #lobby.hidden { opacity: 0; transform: scale(0.96); pointer-events: none; }
51
+ .lobby-title {
52
+ font-family: 'Instrument Serif', serif;
53
+ font-size: clamp(40px, 6vw, 64px);
54
+ letter-spacing: -1px;
55
+ margin: 0;
56
+ text-align: center;
57
+ }
58
+ .lobby-title em { font-style: italic; color: var(--accent); }
59
+ .lobby-sub {
60
+ font-family: 'Geist Mono', monospace;
61
+ font-size: 11px; text-transform: uppercase;
62
+ letter-spacing: 0.22em; color: var(--dim);
63
+ margin: -1rem 0 0 0;
64
+ text-align: center;
65
+ }
66
+ .lobby-controls {
67
+ display: flex; flex-direction: column; gap: 1.25rem;
68
+ width: 100%; max-width: 360px;
69
+ }
70
+ .lobby-row {
71
+ display: flex; justify-content: space-between; align-items: center; gap: 1rem;
72
+ }
73
+ .lobby-row label {
74
+ font-family: 'Geist Mono', monospace; font-size: 12px;
75
+ text-transform: uppercase; letter-spacing: 0.1em; color: var(--dim);
76
+ }
77
+ .lobby-row input {
78
+ width: 80px; background: var(--surface);
79
+ border: 1px solid var(--border); border-radius: 6px;
80
+ color: var(--cream); font-family: 'Geist Mono', monospace;
81
+ font-size: 14px; padding: 8px 12px; text-align: center;
82
+ outline: none; transition: border-color 0.15s;
83
+ }
84
+ .lobby-row input:focus { border-color: var(--accent); }
85
+ .lobby-row input[type=range] {
86
+ width: 120px; -webkit-appearance: none; appearance: none;
87
+ background: transparent; height: 4px;
88
+ }
89
+ .lobby-row input[type=range]::-webkit-slider-runnable-track {
90
+ height: 2px; background: var(--border); border-radius: 1px;
91
+ }
92
+ .lobby-row input[type=range]::-webkit-slider-thumb {
93
+ -webkit-appearance: none; width: 14px; height: 14px;
94
+ border-radius: 50%; background: var(--accent); border: none;
95
+ cursor: pointer; margin-top: -6px;
96
+ }
97
+ .lobby-start {
98
+ width: 100%; padding: 14px; border: none; border-radius: 9999px;
99
+ background: var(--accent); color: #fff;
100
+ font-family: 'Geist Mono', monospace; font-size: 13px;
101
+ text-transform: uppercase; letter-spacing: 0.18em;
102
+ cursor: pointer; transition: opacity 0.15s, transform 0.15s;
103
+ margin-top: 0.5rem;
104
+ }
105
+ .lobby-start:hover { opacity: 0.9; transform: translateY(-1px); }
106
+ .lobby-start:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
107
+
108
+ /* ── Game UI ── */
109
+ #game {
110
+ display: none; flex-direction: column; height: 100vh;
111
+ opacity: 0; transition: opacity 0.5s ease;
112
+ }
113
+ #game.visible { display: flex; opacity: 1; }
114
+
115
+ .status-bar {
116
+ display: flex; align-items: center; justify-content: space-between;
117
+ padding: 8px 16px; background: var(--surface);
118
+ border-bottom: 1px solid var(--border);
119
+ font-size: 12px; letter-spacing: 0.02em;
120
+ flex-shrink: 0;
121
+ }
122
+ .status-left { color: var(--accent); font-weight: 500; font-family: 'Geist Mono', monospace; }
123
+
124
+ .main-layout {
125
+ display: flex; flex: 1; min-height: 0;
126
+ }
127
+ .scene-wrap {
128
+ flex: 1; position: relative; background: #080a08;
129
+ min-height: 0;
130
+ }
131
+ .scene-canvas { position: absolute; inset: 0; }
132
+ .scene-canvas canvas { display: block; width: 100% !important; height: 100% !important; }
133
+
134
+ .sidebar {
135
+ width: 300px; flex-shrink: 0; display: flex; flex-direction: column;
136
+ gap: 6px; padding: 6px; background: var(--surface);
137
+ border-left: 1px solid var(--border); overflow-y: auto;
138
+ }
139
+
140
+ .panel {
141
+ background: var(--bg); border: 1px solid var(--border2);
142
+ border-radius: 8px; padding: 10px 12px;
143
+ }
144
+ .panel.grow { flex: 1; min-height: 0; }
145
+ .panel h3 {
146
+ font-size: 10px; color: var(--dim); margin: 0 0 8px 0;
147
+ text-transform: uppercase; letter-spacing: 0.18em;
148
+ font-family: 'Geist Mono', monospace;
149
+ }
150
+
151
+ .detail-panel {
152
+ background: var(--bg); border: 1px solid var(--border2);
153
+ border-left: 3px solid var(--ac, var(--accent));
154
+ border-radius: 8px; overflow: hidden;
155
+ }
156
+ .detail-panel.empty {
157
+ border-left-color: var(--border2); color: var(--faint);
158
+ padding: 20px 16px; text-align: center; font-size: 12px; font-style: italic;
159
+ }
160
+ .dp-header {
161
+ display: flex; align-items: center; gap: 10px; padding: 10px 12px;
162
+ background: linear-gradient(135deg, #141210, var(--bg));
163
+ }
164
+ .dp-avatar {
165
+ width: 34px; height: 34px; border-radius: 50%;
166
+ background: var(--ac, var(--accent));
167
+ display: flex; align-items: center; justify-content: center;
168
+ font-weight: 700; color: #000; font-size: 14px;
169
+ box-shadow: 0 0 14px var(--ac, var(--accent));
170
+ }
171
+ .dp-name { font-weight: 600; font-size: 13.5px; }
172
+ .dp-loc { font-size: 10.5px; color: var(--dim); font-family: 'Geist Mono', monospace; }
173
+ .dp-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; font-size: 12px; }
174
+ .dp-section { display: flex; justify-content: space-between; align-items: flex-start; gap: 6px; }
175
+ .dp-section label { color: var(--dim); min-width: 48px; }
176
+ .dp-hp { color: var(--accent); }
177
+ .dp-score { color: #f9ca24; }
178
+ .skills-list { display: flex; flex-wrap: wrap; gap: 3px; justify-content: flex-end; }
179
+ .skill-badge { background: #1a1714; padding: 2px 6px; border-radius: 4px; font-size: 10px; color: var(--accent); }
180
+ .skill-badge.none { color: #555; }
181
+
182
+ .lb-row {
183
+ display: flex; align-items: center; gap: 8px;
184
+ padding: 5px 6px; border-radius: 4px; cursor: pointer;
185
+ font-size: 12px; transition: background 0.12s;
186
+ }
187
+ .lb-row:hover { background: #1a1714; }
188
+ .lb-row.dead { opacity: 0.35; }
189
+ .lb-token {
190
+ width: 20px; height: 20px; border-radius: 50%;
191
+ display: flex; align-items: center; justify-content: center;
192
+ font-weight: 700; color: #000;
193
+ font-size: 10px; flex-shrink: 0;
194
+ }
195
+ .lb-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
196
+ .lb-score { color: var(--accent); font-weight: 600; font-family: 'Geist Mono', monospace; }
197
+ .lb-status { font-size: 11px; }
198
+
199
+ .ev-feed { max-height: 100%; overflow-y: auto; font-size: 10.5px; line-height: 1.5; }
200
+ .ev-feed::-webkit-scrollbar { width: 3px; }
201
+ .ev-feed::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
202
+ .ev-row { padding: 3px 0; border-bottom: 1px solid #1a1714; color: #b8a89a; }
203
+ .ev-row.muted { color: #444; font-style: italic; }
204
+ .ev-row.kill { color: var(--accent); }
205
+ .ev-row.loot { color: #f9ca24; }
206
+ .msg-row { font-size: 9.5px; }
207
+ .tab-btn { user-select: none; }
208
+ .tab-btn:hover { background: #1a1714 !important; }
209
+
210
+ .controls-bar {
211
+ display: flex; align-items: center; gap: 8px;
212
+ padding: 8px 16px; background: var(--surface);
213
+ border-top: 1px solid var(--border); flex-shrink: 0;
214
+ }
215
+ .controls-bar button {
216
+ background: #1a1714; border: 1px solid var(--border);
217
+ color: var(--cream); padding: 8px 16px; border-radius: 6px;
218
+ cursor: pointer; font-family: 'Geist Mono', monospace;
219
+ font-size: 12px; transition: all 0.12s;
220
+ }
221
+ .controls-bar button:hover { background: #2a2420; border-color: var(--accent); }
222
+ .controls-bar button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
223
+ .controls-bar button.primary:hover { opacity: 0.9; }
224
+ .controls-bar button:disabled { opacity: 0.3; cursor: not-allowed; }
225
+ .controls-bar .hint { font-size: 10.5px; color: var(--faint); margin-left: auto; font-family: 'Geist Mono', monospace; }
226
+
227
+ .loading-overlay {
228
+ position: absolute; inset: 0; z-index: 10;
229
+ display: flex; align-items: center; justify-content: center;
230
+ background: rgba(8, 10, 8, 0.75);
231
+ opacity: 0; pointer-events: none;
232
+ transition: opacity 0.2s ease;
233
+ }
234
+ .loading-overlay.active { opacity: 1; pointer-events: auto; }
235
+ .loading-spinner {
236
+ display: flex; flex-direction: column; align-items: center; gap: 10px;
237
+ color: var(--dim); font-family: 'Geist Mono', monospace; font-size: 12px;
238
+ }
239
+ .loading-spinner .spin {
240
+ width: 28px; height: 28px; border: 2px solid var(--border);
241
+ border-top-color: var(--accent); border-radius: 50%;
242
+ animation: spin 0.7s linear infinite;
243
+ }
244
+ @keyframes spin { to { transform: rotate(360deg); } }
245
+
246
+ ::-webkit-scrollbar { width: 5px; }
247
+ ::-webkit-scrollbar-track { background: transparent; }
248
+ ::-webkit-scrollbar-thumb { background: #2a2420; border-radius: 3px; }
249
+ </style>
250
+ </head>
251
+ <body>
252
+
253
+ <div id="lobby">
254
+ <div>
255
+ <h1 class="lobby-title">Grid <em>Royale</em></h1>
256
+ <p class="lobby-sub">Battle Royale · LLM Agents</p>
257
+ </div>
258
+ <div class="lobby-controls">
259
+ <div class="lobby-row">
260
+ <label>Grid</label>
261
+ <input type="range" id="gridSize" min="10" max="30" value="20" step="1">
262
+ <span class="mono" id="gridSizeVal" style="color:var(--cream);font-size:14px;width:32px;text-align:center">20</span>
263
+ </div>
264
+ <div class="lobby-row">
265
+ <label>Agents</label>
266
+ <input type="range" id="numAgents" min="2" max="8" value="4" step="1">
267
+ <span class="mono" id="numAgentsVal" style="color:var(--cream);font-size:14px;width:32px;text-align:center">4</span>
268
+ </div>
269
+ <div class="lobby-row">
270
+ <label>Chests</label>
271
+ <input type="range" id="numChests" min="5" max="50" value="15" step="1">
272
+ <span class="mono" id="numChestsVal" style="color:var(--cream);font-size:14px;width:32px;text-align:center">15</span>
273
+ </div>
274
+ <button class="lobby-start" id="startBtn">▶ Start Game</button>
275
+ </div>
276
+ </div>
277
+
278
+ <div id="game">
279
+ <div class="status-bar">
280
+ <span class="status-left" id="statusLeft">⏱ Turn 0/50 · Alive 0/0</span>
281
+ </div>
282
+ <div class="main-layout">
283
+ <div class="scene-wrap" id="sceneWrap">
284
+ <div class="scene-canvas" id="sceneCanvas"></div>
285
+ <div class="loading-overlay" id="loadingOverlay">
286
+ <div class="loading-spinner">
287
+ <div class="spin"></div>
288
+ <span id="loadingText">Thinking...</span>
289
+ </div>
290
+ </div>
291
+ </div>
292
+ <div class="sidebar" id="sidebar"></div>
293
+ </div>
294
+ <div class="controls-bar">
295
+ <button class="primary" id="stepBtn">▶ Step</button>
296
+ <button id="autoBtn">⏩ Auto</button>
297
+ <span class="hint">drag to rotate · scroll to zoom · click an agent</span>
298
+ </div>
299
+ </div>
300
+
301
+ <script>
302
+ const API = ''; // same origin
303
+
304
+ let state = null;
305
+ let selectedId = null;
306
+ let gameStarted = false;
307
+ let autoRunning = false;
308
+
309
+ const AGENT_COLORS = {
310
+ agent_0: '#ff6b6b', agent_1: '#4ecdc4', agent_2: '#45b7d1',
311
+ agent_3: '#f9ca24', agent_4: '#a29bfe', agent_5: '#fd79a8',
312
+ agent_6: '#00b894', agent_7: '#e17055',
313
+ };
314
+
315
+ /* ── Lobby controls ── */
316
+ ['gridSize','numAgents','numChests'].forEach(id => {
317
+ const el = document.getElementById(id);
318
+ const val = document.getElementById(id + 'Val');
319
+ el.addEventListener('input', () => { val.textContent = el.value; });
320
+ });
321
+
322
+ /* ── Three.js setup ── */
323
+ const TILE = 1.6;
324
+
325
+ async function loadThree() {
326
+ if (window.__gr3d && window.__gr3d.THREE) return;
327
+ window.__gr3d = {};
328
+ const THREE_mod = await import('three');
329
+ const { OrbitControls } = await import('https://unpkg.com/three@0.160.0/examples/jsm/controls/OrbitControls.js');
330
+ window.__gr3d.THREE = THREE_mod;
331
+ window.__gr3d.OrbitControls = OrbitControls;
332
+ }
333
+
334
+ let app = null;
335
+ function initScene() {
336
+ if (app) return;
337
+ const THREE = window.__gr3d.THREE;
338
+ const OrbitControls = window.__gr3d.OrbitControls;
339
+ const wrap = document.getElementById('sceneCanvas');
340
+ if (!wrap) return;
341
+
342
+ const scene = new THREE.Scene();
343
+ scene.background = new THREE.Color(0x080a08);
344
+ scene.fog = new THREE.FogExp2(0x080a08, 0.015);
345
+
346
+ const w = wrap.clientWidth || 800;
347
+ const h = wrap.clientHeight || 500;
348
+ const camera = new THREE.PerspectiveCamera(40, w/h, 0.1, 1000);
349
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
350
+ renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2));
351
+ renderer.setSize(w, h);
352
+ renderer.shadowMap.enabled = true;
353
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
354
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
355
+ renderer.toneMappingExposure = 1.0;
356
+ wrap.appendChild(renderer.domElement);
357
+
358
+ const hemi = new THREE.HemisphereLight(0x8a9ab0, 0x0a0806, 0.6);
359
+ scene.add(hemi);
360
+ const sun = new THREE.DirectionalLight(0xffe8c8, 1.2);
361
+ sun.position.set(18, 30, 14);
362
+ sun.castShadow = true;
363
+ sun.shadow.mapSize.set(2048, 2048);
364
+ sun.shadow.camera.left = -25; sun.shadow.camera.right = 25;
365
+ sun.shadow.camera.top = 25; sun.shadow.camera.bottom = -25;
366
+ sun.shadow.camera.near = 1; sun.shadow.camera.far = 80;
367
+ scene.add(sun);
368
+ const fill = new THREE.DirectionalLight(0x506878, 0.3);
369
+ fill.position.set(-12, 18, -8);
370
+ scene.add(fill);
371
+
372
+ const ground = new THREE.Mesh(
373
+ new THREE.PlaneGeometry(200, 200),
374
+ new THREE.MeshStandardMaterial({ color: 0x060806, roughness: 1 })
375
+ );
376
+ ground.rotation.x = -Math.PI/2;
377
+ ground.position.y = -0.5;
378
+ ground.receiveShadow = true;
379
+ scene.add(ground);
380
+
381
+ const tileGroup = new THREE.Group(); scene.add(tileGroup);
382
+ const objGroup = new THREE.Group(); scene.add(objGroup);
383
+ const agentGroup = new THREE.Group(); scene.add(agentGroup);
384
+
385
+ const particleCount = 200;
386
+ const pg = new THREE.BufferGeometry();
387
+ const pa = new Float32Array(particleCount * 3);
388
+ const sa = new Float32Array(particleCount);
389
+ for (let i = 0; i < particleCount; i++) {
390
+ pa[i*3] = (Math.random() - 0.5) * 28;
391
+ pa[i*3+1] = Math.random() * 10 + 1;
392
+ pa[i*3+2] = (Math.random() - 0.5) * 28;
393
+ sa[i] = Math.random() * Math.PI * 2;
394
+ }
395
+ pg.setAttribute('position', new THREE.BufferAttribute(pa, 3));
396
+ pg.setAttribute('seed', new THREE.BufferAttribute(sa, 1));
397
+ const particles = new THREE.Points(pg, new THREE.PointsMaterial({
398
+ color: 0x603030, size: 0.05, transparent: true, opacity: 0.3,
399
+ blending: THREE.AdditiveBlending, depthWrite: false,
400
+ }));
401
+ scene.add(particles);
402
+
403
+ const controls = new OrbitControls(camera, renderer.domElement);
404
+ controls.enableDamping = true;
405
+ controls.dampingFactor = 0.08;
406
+ controls.minDistance = 10;
407
+ controls.maxDistance = 45;
408
+ controls.maxPolarAngle = Math.PI / 2.3;
409
+ controls.target.set(0, 0, 0);
410
+ camera.position.set(18, 20, 18);
411
+ controls.update();
412
+
413
+ function resize() {
414
+ const ww = wrap.clientWidth || 800;
415
+ const hh = wrap.clientHeight || 500;
416
+ renderer.setSize(ww, hh);
417
+ camera.aspect = ww / hh;
418
+ camera.updateProjectionMatrix();
419
+ }
420
+ window.addEventListener('resize', resize);
421
+
422
+ const raycaster = new THREE.Raycaster();
423
+ const mouse = new THREE.Vector2();
424
+ renderer.domElement.addEventListener('click', e => {
425
+ const rect = renderer.domElement.getBoundingClientRect();
426
+ mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
427
+ mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
428
+ raycaster.setFromCamera(mouse, camera);
429
+ const meshes = [];
430
+ agentGroup.traverse(o => { if (o.isMesh && o.userData.aid) meshes.push(o); });
431
+ const hits = raycaster.intersectObjects(meshes);
432
+ selectAgent(hits.length ? hits[0].object.userData.aid : null);
433
+ });
434
+
435
+ const clock = new THREE.Clock();
436
+ function animate() {
437
+ requestAnimationFrame(animate);
438
+ const dt = clock.getDelta();
439
+ const t = clock.elapsedTime;
440
+ controls.update();
441
+ const pPos = particles.geometry.attributes.position;
442
+ const pSeed = particles.geometry.attributes.seed;
443
+ for (let i = 0; i < particleCount; i++) {
444
+ const s = pSeed.array[i];
445
+ pPos.array[i*3+1] += Math.sin(t * 0.5 + s) * 0.002;
446
+ pPos.array[i*3] += Math.cos(t * 0.25 + s) * 0.003;
447
+ }
448
+ pPos.needsUpdate = true;
449
+ agentGroup.children.forEach(a => {
450
+ if (a.userData.baseY !== undefined) {
451
+ a.position.y = a.userData.baseY + Math.sin(t * 2 + a.userData.phase) * 0.08;
452
+ a.rotation.y += dt * 0.5;
453
+ }
454
+ });
455
+ renderer.render(scene, camera);
456
+ }
457
+ animate();
458
+
459
+ function clearGroup(g) {
460
+ while (g.children.length) {
461
+ const c = g.children.pop();
462
+ c.traverse && c.traverse(o => {
463
+ if (o.geometry) o.geometry.dispose();
464
+ if (o.material) {
465
+ if (Array.isArray(o.material)) o.material.forEach(m => m.dispose());
466
+ else o.material.dispose();
467
+ }
468
+ });
469
+ }
470
+ }
471
+
472
+ function build() {
473
+ clearGroup(tileGroup); clearGroup(objGroup); clearGroup(agentGroup);
474
+ if (!state) return;
475
+ const size = state.grid_size;
476
+ const half = (size - 1) / 2;
477
+ const tiles = state.tiles || {};
478
+ const agents = state.agents || [];
479
+
480
+ for (let x = 0; x < size; x++) {
481
+ for (let y = 0; y < size; y++) {
482
+ const k = x + ',' + y;
483
+ const tile = tiles[k] || {};
484
+ const wx = (x - half) * TILE;
485
+ const wz = (y - half) * TILE;
486
+
487
+ const tileMat = new THREE.MeshStandardMaterial({
488
+ color: 0x3a5a3a, roughness: 0.9, metalness: 0.05,
489
+ });
490
+ const tileMesh = new THREE.Mesh(new THREE.BoxGeometry(TILE*0.96, 0.25, TILE*0.96), tileMat);
491
+ tileMesh.position.set(wx, -0.125, wz);
492
+ tileMesh.receiveShadow = true;
493
+ tileGroup.add(tileMesh);
494
+
495
+ const topMat = new THREE.MeshStandardMaterial({
496
+ color: 0x5a8a4a, roughness: 0.8, metalness: 0.05,
497
+ });
498
+ const top = new THREE.Mesh(new THREE.BoxGeometry(TILE*0.94, 0.04, TILE*0.94), topMat);
499
+ top.position.set(wx, 0.02, wz);
500
+ tileGroup.add(top);
501
+
502
+ if (tile.loot) {
503
+ const chest = new THREE.Mesh(
504
+ new THREE.BoxGeometry(0.28, 0.22, 0.28),
505
+ new THREE.MeshStandardMaterial({ color: 0xf9ca24, emissive: 0xf9ca24, emissiveIntensity: 0.6, roughness: 0.3, metalness: 0.5 })
506
+ );
507
+ chest.position.set(wx, 0.22, wz);
508
+ chest.userData.baseY = 0.22;
509
+ chest.userData.phase = Math.random() * Math.PI * 2;
510
+ objGroup.add(chest);
511
+ const glow = new THREE.PointLight(0xf9ca24, 0.3, 2, 2);
512
+ glow.position.set(wx, 0.2, wz);
513
+ objGroup.add(glow);
514
+ }
515
+ }
516
+ }
517
+
518
+ agents.forEach(a => {
519
+ if (!a.alive) return;
520
+ const ax = (a.x - half) * TILE;
521
+ const az = (a.y - half) * TILE;
522
+ const col = parseInt(AGENT_COLORS[a.id]?.slice(1) || 'ffffff', 16);
523
+ const shielded = a.shielded;
524
+
525
+ const pl = new THREE.PointLight(col, 0.5, 3, 2);
526
+ pl.position.set(ax, 0.8, az);
527
+ agentGroup.add(pl);
528
+
529
+ const ring = new THREE.Mesh(
530
+ new THREE.RingGeometry(0.3, 0.36, 24),
531
+ new THREE.MeshBasicMaterial({ color: shielded ? 0x60c8ff : col, side: THREE.DoubleSide, transparent: true, opacity: shielded ? 0.8 : 0.4 })
532
+ );
533
+ ring.rotation.x = -Math.PI/2;
534
+ ring.position.set(ax, 0.04, az);
535
+ agentGroup.add(ring);
536
+
537
+ const figMat = new THREE.MeshStandardMaterial({
538
+ color: col, emissive: shielded ? 0x60c8ff : col,
539
+ emissiveIntensity: shielded ? 0.8 : 0.4,
540
+ roughness: 0.3, metalness: 0.3,
541
+ });
542
+ const fig = new THREE.Mesh(new THREE.CapsuleGeometry(0.16, 0.5, 6, 10), figMat);
543
+ fig.position.set(ax, 0.42, az);
544
+ fig.castShadow = true;
545
+ fig.userData.aid = a.id;
546
+ fig.userData.baseY = 0.42;
547
+ fig.userData.phase = Math.random() * Math.PI * 2;
548
+ agentGroup.add(fig);
549
+
550
+ if (shielded) {
551
+ const sh = new THREE.Mesh(
552
+ new THREE.SphereGeometry(0.35, 12, 12),
553
+ new THREE.MeshStandardMaterial({ color: 0x60c8ff, transparent: true, opacity: 0.15, emissive: 0x60c8ff, emissiveIntensity: 0.3, side: THREE.BackSide })
554
+ );
555
+ sh.position.set(ax, 0.5, az);
556
+ sh.userData.baseY = 0.5;
557
+ sh.userData.phase = Math.random() * Math.PI * 2;
558
+ agentGroup.add(sh);
559
+ }
560
+
561
+ const canvas = document.createElement('canvas');
562
+ canvas.width = 64; canvas.height = 64;
563
+ const ctx = canvas.getContext('2d');
564
+ ctx.fillStyle = '#' + col.toString(16).padStart(6, '0');
565
+ ctx.beginPath(); ctx.arc(32, 32, 28, 0, Math.PI * 2); ctx.fill();
566
+ ctx.fillStyle = '#000';
567
+ ctx.font = 'bold 28px monospace';
568
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
569
+ ctx.fillText(a.name.charAt(0).toUpperCase(), 32, 34);
570
+ const tex = new THREE.CanvasTexture(canvas);
571
+ const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }));
572
+ sprite.position.set(ax, 1.1, az);
573
+ sprite.scale.set(0.5, 0.5, 1);
574
+ sprite.userData.aid = a.id;
575
+ agentGroup.add(sprite);
576
+ });
577
+ }
578
+
579
+ app = { scene, camera, renderer, controls, build, resize };
580
+ }
581
+
582
+ function renderScene() {
583
+ if (app) app.build();
584
+ }
585
+
586
+ /* ── UI updates ── */
587
+ function buildSidebar() {
588
+ if (!state) return;
589
+ const agents = state.agents || [];
590
+ const sidebar = document.getElementById('sidebar');
591
+
592
+ const selected = agents.find(a => a.id === selectedId);
593
+ let detailHtml = '';
594
+ let traceHtml = '';
595
+ if (selected) {
596
+ const abilities = selected.abilities || [];
597
+ const abHtml = abilities.length
598
+ ? abilities.map(ab => `<span class="skill-badge">${ab.name}${ab.uses_remaining === null ? ' ∞' : ' ' + ab.uses_remaining}</span>`).join('')
599
+ : '<span class="skill-badge none">none</span>';
600
+ const msgs = selected.messages || [];
601
+ const msgHtml = msgs.length
602
+ ? msgs.map(m => `<div class="msg-row ${m.role}" style="border-left:2px solid ${m.role === 'tool' ? 'var(--faint)' : m.role === 'user' ? 'var(--accent)' : '#5a8a4a'};padding:3px 6px;margin:2px 0;font-size:9.5px;overflow:hidden">
603
+ <span style="color:${m.role === 'system' ? 'var(--dim)' : m.role === 'user' ? 'var(--accent)' : m.role === 'tool' ? 'var(--faint)' : 'var(--cream)'};font-weight:500;text-transform:uppercase;font-size:8.5px">${m.role}</span>
604
+ <div style="color:${m.role === 'system' ? '#665' : m.role === 'user' ? '#ddd' : '#998'};word-break:break-word;white-space:pre-wrap;max-height:60px;overflow-y:auto">${esc(m.content || '').slice(0, 300)}</div>
605
+ </div>`).join('')
606
+ : '<div class="ev-row muted">No messages yet.</div>';
607
+ detailHtml = `
608
+ <div class="detail-panel" style="--ac:${AGENT_COLORS[selected.id] || '#888'}">
609
+ <div class="dp-header" style="cursor:pointer" onclick="selectAgent(null)">
610
+ <span class="dp-avatar">${selected.name.charAt(0).toUpperCase()}</span>
611
+ <div style="flex:1">
612
+ <div class="dp-name">${selected.name}</div>
613
+ <div class="dp-loc">📍 (${selected.x}, ${selected.y}) · Kills: ${selected.kills}</div>
614
+ </div>
615
+ <span style="color:var(--faint);font-size:11px">✕</span>
616
+ </div>
617
+ <div class="dp-body">
618
+ <div class="dp-section"><label>HP</label><span class="dp-hp">${selected.hp}/100</span></div>
619
+ <div class="dp-section"><label>Score</label><span class="dp-score">${selected.score}</span></div>
620
+ <div class="dp-section"><label>Status</label><span>${selected.shielded ? '🛡️ Shielded' : '⚔️ Active'}</span></div>
621
+ <div class="dp-section"><label>Abilities</label><div class="skills-list">${abHtml}</div></div>
622
+ </div>
623
+ </div>
624
+ <div class="panel" style="flex:1;min-height:0;display:flex;flex-direction:column">
625
+ <div style="display:flex;gap:3px;margin-bottom:4px">
626
+ <span class="tab-btn active" id="tabSystem" style="flex:1;text-align:center;padding:4px;border-radius:4px;background:var(--border2);cursor:pointer;font-size:9.5px;text-transform:uppercase;letter-spacing:0.08em;color:var(--cream)" onclick="switchAgentTab('system')">System</span>
627
+ <span class="tab-btn" id="tabHistory" style="flex:1;text-align:center;padding:4px;border-radius:4px;background:transparent;cursor:pointer;font-size:9.5px;text-transform:uppercase;letter-spacing:0.08em;color:var(--dim)" onclick="switchAgentTab('history')">History</span>
628
+ <span class="tab-btn" id="tabTools" style="flex:1;text-align:center;padding:4px;border-radius:4px;background:transparent;cursor:pointer;font-size:9.5px;text-transform:uppercase;letter-spacing:0.08em;color:var(--dim)" onclick="switchAgentTab('tools')">Tools</span>
629
+ </div>
630
+ <div class="ev-feed" id="agentTabContent" style="flex:1;min-height:0;overflow-y:auto">
631
+ ${buildAgentTab(selected.id, 'system')}
632
+ </div>
633
+ </div>`;
634
+ } else {
635
+ const sorted = [...agents].sort((a, b) => b.score - a.score);
636
+ const lbHtml = sorted.map(a => `
637
+ <div class="lb-row${a.alive ? '' : ' dead'}" data-aid="${a.id}">
638
+ <span class="lb-token" style="background:${AGENT_COLORS[a.id] || '#888'}">${a.name.charAt(0).toUpperCase()}</span>
639
+ <span class="lb-name">${a.name}</span>
640
+ <span class="lb-score">${a.score}</span>
641
+ <span class="lb-status">${a.alive ? '✦' : '✗'}</span>
642
+ </div>
643
+ `).join('');
644
+ detailHtml = '<div class="detail-panel empty">Click an agent to inspect</div>';
645
+ traceHtml = `
646
+ <div class="panel grow">
647
+ <h3>Agent Trace</h3>
648
+ <div class="ev-feed" id="traceFeed">${buildAllTrace()}</div>
649
+ </div>`;
650
+ detailHtml = `
651
+ ${detailHtml}
652
+ <div class="panel" style="flex-shrink:0">
653
+ <h3>Leaderboard</h3>
654
+ ${lbHtml}
655
+ </div>
656
+ ${traceHtml}`;
657
+ }
658
+
659
+ sidebar.innerHTML = detailHtml;
660
+
661
+ document.querySelectorAll('.lb-row[data-aid]').forEach(el => {
662
+ el.addEventListener('click', () => selectAgent(el.dataset.aid));
663
+ });
664
+ }
665
+
666
+ function switchAgentTab(tab) {
667
+ const aid = selectedId;
668
+ if (!aid) return;
669
+ ['System','History','Tools'].forEach(t => {
670
+ const id = 'tab' + t;
671
+ const el = document.getElementById(id);
672
+ if (!el) return;
673
+ const active = t.toLowerCase() === tab;
674
+ el.className = 'tab-btn' + (active ? ' active' : '');
675
+ el.style.background = active ? 'var(--border2)' : 'transparent';
676
+ el.style.color = active ? 'var(--cream)' : 'var(--dim)';
677
+ });
678
+ document.getElementById('agentTabContent').innerHTML = buildAgentTab(aid, tab);
679
+ }
680
+
681
+ function buildAgentTab(aid, tab) {
682
+ if (tab === 'system') return buildAgentSystem(aid);
683
+ if (tab === 'history') return buildAgentHistory(aid);
684
+ if (tab === 'tools') return buildAgentTools();
685
+ return '';
686
+ }
687
+
688
+ function buildAgentSystem(aid) {
689
+ const agent = (state.agents || []).find(a => a.id === aid);
690
+ if (!agent) return '<div class="ev-row muted">No data.</div>';
691
+ const msgs = agent.messages || [];
692
+ const sys = msgs.filter(m => m.role === 'system');
693
+ return sys.length
694
+ ? sys.map(m => `<div class="ev-row" style="border-left:2px solid #5a8a4a;padding:4px 6px;margin:2px 0;background:rgba(90,138,74,0.06)">
695
+ <span style="color:#5a8a4a;font-weight:500;text-transform:uppercase;font-size:8.5px;letter-spacing:0.1em">system</span>
696
+ <div style="color:#887;font-family:'Geist Mono',monospace;font-size:8.5px;white-space:pre-wrap;word-break:break-word;max-height:80px;overflow-y:auto;margin-top:2px">${esc(m.content || '').slice(0, 600)}</div>
697
+ </div>`).join('')
698
+ : '<div class="ev-row muted">No system prompt.</div>';
699
+ }
700
+
701
+ function buildAgentHistory(aid) {
702
+ const agent = (state.agents || []).find(a => a.id === aid);
703
+ if (!agent) return '<div class="ev-row muted">No data.</div>';
704
+ const msgs = (agent.messages || []).filter(m => m.role !== 'system');
705
+ if (!msgs.length) return '<div class="ev-row muted">No messages yet.</div>';
706
+ return msgs.map(m => {
707
+ const roleColors = {user: 'var(--accent)', assistant: 'var(--cream)', tool: 'var(--faint)'};
708
+ const bgColors = {user: 'rgba(231,76,60,0.04)', assistant: 'rgba(244,236,222,0.03)', tool: 'rgba(90,80,72,0.04)'};
709
+ if (m.role === 'assistant' && m.tool_calls) {
710
+ const calls = (m.tool_calls || []).map(tc =>
711
+ `${tc.function.name}(${esc(JSON.stringify(tc.function.arguments || {}))})`
712
+ ).join(', ');
713
+ return `<div class="ev-row" style="border-left:2px solid var(--cream);padding:3px 6px;margin:2px 0;background:rgba(244,236,222,0.03)">
714
+ <span style="color:var(--cream);font-weight:500;text-transform:uppercase;font-size:8.5px;letter-spacing:0.1em">tool_calls</span>
715
+ <div style="color:#bb9;font-family:'Geist Mono',monospace;font-size:8.5px;white-space:pre-wrap;word-break:break-word;margin-top:2px">${calls}</div>
716
+ </div>`;
717
+ }
718
+ const label = m.role === 'tool' ? 'tool_result' : m.role;
719
+ const content = m.role === 'assistant' ? (m.content || '(tool calls)') : (m.content || '');
720
+ return `<div class="ev-row" style="border-left:2px solid ${roleColors[m.role] || '#555'};padding:3px 6px;margin:2px 0;background:${bgColors[m.role] || 'transparent'}">
721
+ <span style="color:${roleColors[m.role] || '#555'};font-weight:500;text-transform:uppercase;font-size:8.5px;letter-spacing:0.1em">${label}</span>
722
+ <div style="color:#aa9;font-family:'Geist Mono',monospace;font-size:8.5px;white-space:pre-wrap;word-break:break-word;max-height:80px;overflow-y:auto;margin-top:2px">${esc(content).slice(0, 500)}</div>
723
+ </div>`;
724
+ }).join('');
725
+ }
726
+
727
+ function buildAgentTools() {
728
+ const schemas = state.tool_schemas;
729
+ if (!schemas || !schemas.length) return '<div class="ev-row muted">No tools defined.</div>';
730
+ return schemas.map(t => {
731
+ const name = t.function?.name || t.name || '?';
732
+ const desc = t.function?.description || t.description || '';
733
+ const params = t.function?.parameters || t.parameters || {};
734
+ const props = params.properties || {};
735
+ const req = params.required || [];
736
+ const propHtml = Object.keys(props).map(k => {
737
+ const p = props[k];
738
+ const type = p.type || 'any';
739
+ const desc2 = p.description ? ` — ${esc(p.description)}` : '';
740
+ const required = req.includes(k) ? ' <span style="color:var(--accent)">*required</span>' : '';
741
+ return `<div style="padding:2px 0;font-size:8.5px;color:#998">
742
+ <span style="color:var(--cream)">${esc(k)}</span><span style="color:var(--dim)">: ${type}${required}</span>
743
+ <span style="color:#665">${desc2}</span>
744
+ </div>`;
745
+ }).join('');
746
+ return `<div class="ev-row" style="border-left:2px solid var(--accent);padding:4px 6px;margin:3px 0">
747
+ <div style="color:var(--cream);font-weight:600;font-size:10px">${esc(name)}</div>
748
+ <div style="color:#887;font-size:9px;margin:2px 0">${esc(desc)}</div>
749
+ ${propHtml ? `<div style="margin-top:3px;border-top:1px solid var(--border2);padding-top:2px">${propHtml}</div>` : ''}
750
+ </div>`;
751
+ }).join('');
752
+ }
753
+
754
+ function buildAllTrace() {
755
+ const log = state.turn_log;
756
+ if (!log || !log.agents) return '<div class="ev-row muted">No turn data yet.</div>';
757
+ const agents = state.agents || [];
758
+ const rows = [];
759
+ for (const aid of Object.keys(log.agents)) {
760
+ const agent = agents.find(a => a.id === aid);
761
+ const name = agent ? agent.name : aid;
762
+ const color = AGENT_COLORS[aid] || '#888';
763
+ const entries = log.agents[aid] || [];
764
+ for (const entry of entries) {
765
+ if (entry.phase === 'llm') {
766
+ const usage = entry.usage ? ` · ${entry.usage.prompt_tokens || '?'}→${entry.usage.completion_tokens || '?'}t` : '';
767
+ rows.push(`<div class="ev-row" style="border-left:2px solid ${color};padding-left:6px;margin:2px 0">
768
+ <span style="color:${color};font-weight:600">${name}</span>
769
+ <span style="color:var(--faint)"> LLM </span>
770
+ <span style="color:var(--dim)">${entry.time_ms}ms${usage}</span>
771
+ </div>`);
772
+ } else if (entry.phase === 'exec') {
773
+ const args = JSON.stringify(entry.args).slice(0, 80);
774
+ rows.push(`<div class="ev-row" style="border-left:2px solid ${color};padding-left:10px;margin:1px 0;font-size:9.5px">
775
+ <span style="color:var(--muted)">r${entry.round} </span>
776
+ <span style="color:var(--cream)">${entry.tool}</span>
777
+ <span style="color:var(--faint)"> ${esc(args)}</span>
778
+ <span style="color:var(--dim);float:right">${entry.time_ms}ms</span>
779
+ <div style="color:#887;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">→ ${esc((entry.result || '').slice(0, 80))}</div>
780
+ </div>`);
781
+ }
782
+ }
783
+ }
784
+ const timing = log.time_ms ? `<div class="ev-row muted" style="border-top:1px solid var(--border);margin-top:4px;padding-top:4px">⏱ Step total: ${log.time_ms}ms</div>` : '';
785
+ return rows.length ? rows.join('') + timing : '<div class="ev-row muted">No turn data yet.</div>';
786
+ }
787
+
788
+ function esc(s) {
789
+ const d = document.createElement('div');
790
+ d.textContent = s;
791
+ return d.innerHTML;
792
+ }
793
+
794
+ function selectAgent(id) {
795
+ selectedId = id;
796
+ buildSidebar();
797
+ }
798
+
799
+ /* ── API calls ── */
800
+ async function api(method, path, body) {
801
+ const opts = { method, headers: { 'Content-Type': 'application/json' } };
802
+ if (body) opts.body = JSON.stringify(body);
803
+ const r = await fetch(API + path, opts);
804
+ return r.json();
805
+ }
806
+
807
+ async function startGame() {
808
+ const gridSize = parseInt(document.getElementById('gridSize').value);
809
+ const numAgents = parseInt(document.getElementById('numAgents').value);
810
+ const numChests = parseInt(document.getElementById('numChests').value);
811
+
812
+ document.getElementById('startBtn').disabled = true;
813
+ document.getElementById('startBtn').textContent = 'Starting...';
814
+
815
+ state = await api('POST', '/api/game/start', {
816
+ grid_size: gridSize,
817
+ max_agents: numAgents,
818
+ num_chests: numChests,
819
+ });
820
+
821
+ state = await api('GET', '/api/game/state');
822
+ gameStarted = true;
823
+
824
+ document.getElementById('lobby').classList.add('hidden');
825
+ const game = document.getElementById('game');
826
+ game.classList.add('visible');
827
+ game.style.display = 'flex';
828
+
829
+ showLoading('Loading scene...');
830
+ try {
831
+ await loadThree();
832
+ if (!app) initScene();
833
+ requestAnimationFrame(() => {
834
+ if (app) app.resize();
835
+ renderScene();
836
+ });
837
+ updateUI();
838
+ } finally {
839
+ hideLoading();
840
+ }
841
+ }
842
+
843
+ function showLoading(label) {
844
+ const el = document.getElementById('loadingOverlay');
845
+ document.getElementById('loadingText').textContent = label;
846
+ el.classList.add('active');
847
+ document.getElementById('stepBtn').disabled = true;
848
+ document.getElementById('autoBtn').disabled = true;
849
+ }
850
+ function hideLoading() {
851
+ document.getElementById('loadingOverlay').classList.remove('active');
852
+ if (state && !state.game_over) {
853
+ const alive = (state.agents || []).filter(a => a.alive).length;
854
+ if (alive > 1) {
855
+ document.getElementById('stepBtn').disabled = false;
856
+ document.getElementById('autoBtn').disabled = false;
857
+ }
858
+ }
859
+ }
860
+
861
+ async function step() {
862
+ showLoading('Executing turn...');
863
+ try {
864
+ state = await api('POST', '/api/game/step');
865
+ renderScene();
866
+ updateUI();
867
+ } finally {
868
+ hideLoading();
869
+ }
870
+ }
871
+
872
+ async function auto() {
873
+ if (autoRunning) {
874
+ autoRunning = false;
875
+ return;
876
+ }
877
+ autoRunning = true;
878
+ document.getElementById('autoBtn').textContent = '⏹ Stop';
879
+ document.getElementById('stepBtn').disabled = true;
880
+ try {
881
+ while (autoRunning) {
882
+ document.getElementById('statusLeft').textContent =
883
+ `⏱ Turn ${(state && state.turn) || 0}/... · Auto-running...`;
884
+ let data;
885
+ try {
886
+ data = await api('POST', '/api/game/step');
887
+ } catch (e) {
888
+ break;
889
+ }
890
+ state = data;
891
+ renderScene();
892
+ updateUI();
893
+ if (state.game_over) break;
894
+ await new Promise(r => setTimeout(r, 100));
895
+ }
896
+ } finally {
897
+ autoRunning = false;
898
+ document.getElementById('autoBtn').textContent = '⏩ Auto';
899
+ const alive = state ? (state.agents || []).filter(a => a.alive).length : 0;
900
+ document.getElementById('stepBtn').disabled = !state || state.game_over || alive <= 1;
901
+ updateUI();
902
+ }
903
+ }
904
+
905
+ function updateUI() {
906
+ if (!state) return;
907
+ const agents = state.agents || [];
908
+ const alive = agents.filter(a => a.alive).length;
909
+ const winner = state.winner || (alive === 1 ? agents.find(a => a.alive)?.name : null);
910
+ const gameOver = state.game_over || alive <= 1;
911
+
912
+ const stepTiming = state.turn_log ? ` · ${state.turn_log.time_ms}ms` : '';
913
+ document.getElementById('statusLeft').textContent =
914
+ `⏱ Turn ${state.turn}/${state.max_turns || 50} · Alive ${alive}/${agents.length}${stepTiming}` +
915
+ (winner ? ` · 🏆 ${winner} Wins!` : gameOver ? ' · Game Over' : '');
916
+
917
+ document.getElementById('stepBtn').disabled = gameOver;
918
+ document.getElementById('autoBtn').disabled = gameOver;
919
+
920
+ buildSidebar();
921
+ }
922
+
923
+ /* ── Event binding ── */
924
+ document.getElementById('startBtn').addEventListener('click', startGame);
925
+ document.getElementById('stepBtn').addEventListener('click', step);
926
+ document.getElementById('autoBtn').addEventListener('click', auto);
927
+
928
+ document.addEventListener('keydown', e => {
929
+ if (e.key === 'Enter' && !gameStarted) startGame();
930
+ });
931
+
932
+ /* ── Resize handler for Three.js ── */
933
+ window.addEventListener('resize', () => { if (app) app.resize(); });
934
+ </script>
935
+ </body>
936
+ </html>
main.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import uvicorn
2
+ from fastapi.staticfiles import StaticFiles
3
+ from api import app
4
+
5
+ app.mount("/", StaticFiles(directory="frontend/static", html=True), name="frontend")
6
+
7
+ if __name__ == "__main__":
8
+ uvicorn.run(app, host="0.0.0.0", port=8000)
modal_vllm.py ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---
2
+ # pytest: false
3
+ # ---
4
+
5
+ # # Run OpenAI-compatible LLM inference with Gemma and vLLM
6
+
7
+ # In this example, we show how to run a vLLM server in OpenAI-compatible mode on Modal.
8
+
9
+ # LLMs do more than just model language: they chat, they produce JSON and XML, they run code, and more.
10
+ # This has complicated their interface far beyond "text-in, text-out".
11
+ # OpenAI's API has emerged as a standard for that interface,
12
+ # and it is supported by open source LLM serving frameworks like [vLLM](https://docs.vllm.ai/en/latest/).
13
+
14
+ # This example is intended to demonstrate the basics of deploying LLM inference on Modal.
15
+ # For more on how to optimize performance, see
16
+ # [this guide](https://modal.com/docs/guide/high-performance-llm-inference)
17
+ # and check out our
18
+ # [LLM Engineer's Almanac](https://modal.com/llm-almanac).
19
+
20
+ # Our examples repository also includes scripts for running clients and load-testing for OpenAI-compatible APIs
21
+ # [here](https://github.com/modal-labs/modal-examples/tree/main/06_gpu_and_ml/llm-serving/openai_compatible).
22
+
23
+ # ## Set up the container image
24
+
25
+ # Our first order of business is to define the environment our server will run in:
26
+ # the [container `Image`](https://modal.com/docs/guide/custom-container).
27
+ # vLLM can be installed with `uv pip`, since Modal [provides the CUDA drivers](https://modal.com/docs/guide/cuda).
28
+
29
+ import json
30
+ from typing import Any
31
+
32
+ import aiohttp
33
+ import modal
34
+
35
+ vllm_image = (
36
+ modal.Image.from_registry("nvidia/cuda:12.9.0-devel-ubuntu22.04", add_python="3.12")
37
+ .entrypoint([])
38
+ .uv_pip_install("vllm==0.21.0")
39
+ .env(
40
+ {
41
+ "HF_XET_HIGH_PERFORMANCE": "1", # faster model transfers
42
+ "VLLM_LOG_STATS_INTERVAL": "1", # more frequent metrics logging
43
+ }
44
+ )
45
+ )
46
+
47
+ # ## Download the model weights
48
+
49
+ # We'll be running a pretrained foundation model --
50
+ # [Google's Gemma 4](https://blog.google/innovation-and-ai/technology/developers-tools/gemma-4/).
51
+ # It can also take images, video, and audio as inputs,
52
+ # though we won't use that here.
53
+
54
+ # We'll use the 26BA4B variant, [`google/gemma-4-26B-A4B-it`](https://huggingface.co/google/gemma-4-26B-A4B-it).
55
+ # This variant is trained with reasoning capabilities, which allow it to
56
+ # enhance the quality of its generated responses.
57
+ # It has `26B`illion parameters, of which `4B`illion are `A`ctive
58
+ # in processing of each token.
59
+
60
+ # You can swap this model out for another by changing the strings below,
61
+ # though you might also need to adjust some of the server configuration as well.
62
+ # A single H200 GPU has enough VRAM to store this 26,000,000,000 parameter model
63
+ # along with a large KV cache.
64
+
65
+
66
+ MODEL_NAME = "google/gemma-4-26B-A4B-it"
67
+ MODEL_REVISION = "47b6801b24d15ff9bcd8c96dfaea0be9ed3a0301" # avoid nasty surprises when repos update!
68
+
69
+ # Although vLLM will download weights from Hugging Face on-demand,
70
+ # we want to cache them so we don't do it every time our server starts.
71
+ # We'll use [Modal Volumes](https://modal.com/docs/guide/volumes) for our cache.
72
+ # Modal Volumes are essentially a "shared disk" that all Modal Functions can access like it's a regular disk.
73
+ # For more on storing model weights on Modal, see
74
+ # [this guide](https://modal.com/docs/guide/model-weights).
75
+
76
+
77
+ hf_cache_vol = modal.Volume.from_name("huggingface-cache", create_if_missing=True)
78
+
79
+ # We'll also cache some of vLLM's JIT compilation artifacts in a Modal Volume.
80
+
81
+ vllm_cache_vol = modal.Volume.from_name("vllm-cache", create_if_missing=True)
82
+
83
+ # ## Configuring vLLM
84
+
85
+ # ### Trading off fast boots and token generation performance
86
+
87
+ # vLLM has embraced dynamic and just-in-time compilation to eke out additional performance without having to write too many custom kernels,
88
+ # e.g. via the Torch compiler and CUDA graph capture.
89
+ # These compilation features incur latency in exchange for lowered latency and higher throughput during generation.
90
+ # This latency is typically tens of seconds to a few minutes, reduced to about ten seconds when loaded from the cache.
91
+ # We make this trade-off controllable with the `FAST_BOOT` variable below.
92
+
93
+ FAST_BOOT = False
94
+
95
+ # If you're running an LLM service that frequently scales from 0 (frequent ["cold starts"](https://modal.com/docs/guide/cold-start))
96
+ # you might want to set this to `True`, or consider [GPU memory snapshots](https://modal.com/docs/guide/memory-snapshots).
97
+ # It's also useful to set this when you're iterating on the server configuration.
98
+
99
+ # If you're running an LLM service that usually has multiple replicas running, then set this to `False` for improved performance.
100
+
101
+ # See the code below for details on the parameters that `FAST_BOOT` controls.
102
+
103
+ # ### Model-specific configuration
104
+
105
+ # Almost all models require some amount of configuration via command-line flags,
106
+ # especially to achieve optimal performance.
107
+
108
+ # We set these flags in the code below, roughly following the
109
+ # [usage guide from the vLLM docs](https://docs.vllm.ai/projects/recipes/en/latest/Google/Gemma4.html).
110
+
111
+ # For instance, we turn off multimodal features to save on [GPU RAM](https://modal.com/gpu-glossary/device-hardware/gpu-ram),
112
+ # and we activate the [built-in multi-token prediction (MTP)](https://blog.google/innovation-and-ai/technology/developers-tools/multi-token-prediction-gemma-4/)
113
+ # speculative decoding for improved throughput at lower concurrencies.
114
+
115
+ SPECULATIVE_MODEL_NAME = "google/gemma-4-26B-A4B-it-assistant"
116
+ SPECULATIVE_MODEL_REVISION = "f188f476dc11dd5bb3014dc861529d316bce49d3"
117
+
118
+ # For more on the performance you can expect when serving your own LLMs, see
119
+ # [our LLM engine performance benchmarks](https://modal.com/llm-almanac).
120
+
121
+ # ## Build a vLLM engine and serve it
122
+
123
+ # The function below spawns a vLLM instance listening at port 8000, serving requests to our model.
124
+ # We wrap it in the [`@modal.web_server` decorator](https://modal.com/docs/guide/webhooks#non-asgi-web-servers)
125
+ # to connect it to the Internet.
126
+
127
+ # The server runs in an independent process, via `subprocess.Popen`, and only starts accepting requests
128
+ # once the model is spun up and the `serve` function returns.
129
+
130
+
131
+ app = modal.App("example-vllm-inference")
132
+
133
+ N_GPU = 1
134
+ MINUTES = 60 # seconds
135
+ VLLM_PORT = 8000
136
+
137
+
138
+ @app.function(
139
+ image=vllm_image,
140
+ gpu=f"H200:{N_GPU}",
141
+ scaledown_window=15 * MINUTES, # how long should we stay up with no requests?
142
+ timeout=10 * MINUTES, # how long should we wait for container start?
143
+ volumes={
144
+ "/root/.cache/huggingface": hf_cache_vol,
145
+ "/root/.cache/vllm": vllm_cache_vol,
146
+ },
147
+ )
148
+ @modal.concurrent( # how many requests can one replica handle? tune carefully!
149
+ max_inputs=100,
150
+ )
151
+ @modal.web_server(port=VLLM_PORT, startup_timeout=10 * MINUTES)
152
+ def serve():
153
+ import json
154
+ import subprocess
155
+
156
+ cmd = [
157
+ "vllm",
158
+ "serve",
159
+ MODEL_NAME,
160
+ "--revision",
161
+ MODEL_REVISION,
162
+ "--served-model-name",
163
+ MODEL_NAME,
164
+ "llm",
165
+ "--host",
166
+ "0.0.0.0",
167
+ "--port",
168
+ str(VLLM_PORT),
169
+ "--uvicorn-log-level=info",
170
+ "--async-scheduling",
171
+ ]
172
+
173
+ # enforce-eager disables both Torch compilation and CUDA graph capture
174
+ # default is no-enforce-eager. see the --compilation-config flag for tighter control
175
+ cmd += ["--enforce-eager" if FAST_BOOT else "--no-enforce-eager"]
176
+
177
+ # assume multiple GPUs are for splitting up large matrix multiplications
178
+ cmd += ["--tensor-parallel-size", str(N_GPU)]
179
+
180
+ # add model-specific configuration
181
+ cmd += [
182
+ # skip multimedia support, just language
183
+ "--limit-mm-per-prompt",
184
+ f"'{json.dumps({'image': 0, 'video': 0, 'audio': 0})}'",
185
+ # enable reasoning and tool use
186
+ "--enable-auto-tool-choice",
187
+ "--reasoning-parser gemma4",
188
+ "--tool-call-parser gemma4",
189
+ ]
190
+
191
+ # add speculative decoding
192
+ cmd += [
193
+ "--speculative-config",
194
+ f"'{json.dumps({'model': SPECULATIVE_MODEL_NAME, 'revision': SPECULATIVE_MODEL_REVISION, 'num_speculative_tokens': 4})}'",
195
+ ]
196
+
197
+ print(*cmd)
198
+
199
+ subprocess.Popen(" ".join(cmd), shell=True)
200
+
201
+
202
+ # ## Deploy the server
203
+
204
+ # To deploy the API on Modal, just run
205
+ # ```bash
206
+ # modal deploy vllm_inference.py
207
+ # ```
208
+
209
+ # This will create a new app on Modal, build the container image for it if it hasn't been built yet,
210
+ # and deploy the app.
211
+
212
+ # ## Interact with the server
213
+
214
+ # Once it is deployed, you'll see a URL appear in the command line,
215
+ # something like `https://your-workspace-name--example-vllm-inference-serve.modal.run`.
216
+
217
+ # You can find [interactive Swagger UI docs](https://swagger.io/tools/swagger-ui/)
218
+ # at the `/docs` route of that URL, i.e. `https://your-workspace-name--example-vllm-inference-serve.modal.run/docs`.
219
+ # These docs describe each route and indicate the expected input and output
220
+ # and translate requests into `curl` commands.
221
+
222
+ # For simple routes like `/health`, which checks whether the server is responding,
223
+ # you can even send a request directly from the docs.
224
+
225
+ # To interact with the API programmatically in Python, we recommend the `openai` library.
226
+
227
+ # See the `client.py` script in the examples repository
228
+ # [here](https://github.com/modal-labs/modal-examples/tree/main/06_gpu_and_ml/llm-serving/openai_compatible)
229
+ # to take it for a spin:
230
+
231
+ # ```bash
232
+ # # pip install openai==1.76.0
233
+ # python openai_compatible/client.py
234
+ # ```
235
+
236
+
237
+ # ## Testing the server
238
+
239
+ # To make it easier to test the server setup, we also include a `local_entrypoint`
240
+ # that does a healthcheck and then hits the server.
241
+
242
+ # If you execute the command
243
+
244
+ # ```bash
245
+ # modal run vllm_inference.py
246
+ # ```
247
+
248
+ # a fresh replica of the server will be spun up on Modal while
249
+ # the code below executes on your local machine.
250
+
251
+ # Think of this like writing simple tests inside of the `if __name__ == "__main__"`
252
+ # block of a Python script, but for cloud deployments!
253
+
254
+
255
+ @app.local_entrypoint()
256
+ async def test(test_timeout=15 * MINUTES, content=None, twice=True):
257
+ url = await serve.get_web_url.aio()
258
+
259
+ system_prompt = {
260
+ "role": "system",
261
+ "content": "You are a pirate who can't help but drop sly reminders that he went to Harvard.",
262
+ }
263
+ if content is None:
264
+ content = "Explain the singular value decomposition."
265
+
266
+ messages = [ # OpenAI chat format
267
+ system_prompt,
268
+ {"role": "user", "content": content},
269
+ ]
270
+
271
+ async with aiohttp.ClientSession(base_url=url) as session:
272
+ print(f"Running health check for server at {url}")
273
+ async with session.get("/health", timeout=test_timeout - 1 * MINUTES) as resp:
274
+ up = resp.status == 200
275
+ assert up, f"Failed health check for server at {url}"
276
+ print(f"Successful health check for server at {url}")
277
+
278
+ print(f"Sending messages to {url}:", *messages, sep="\n\t")
279
+ await _send_request(session, "llm", messages)
280
+ if twice:
281
+ messages[0]["content"] = "You are Jar Jar Binks."
282
+ print(f"Sending messages to {url}:", *messages, sep="\n\t")
283
+ await _send_request(session, "llm", messages)
284
+
285
+
286
+ async def _send_request(
287
+ session: aiohttp.ClientSession, model: str, messages: list
288
+ ) -> None:
289
+ # `stream=True` tells an OpenAI-compatible backend to stream chunks
290
+ payload: dict[str, Any] = {"messages": messages, "model": model, "stream": True}
291
+ # explicitly enable thinking for this model
292
+ payload["chat_template_kwargs"] = {"enable_thinking": True}
293
+
294
+ headers = {"Content-Type": "application/json", "Accept": "text/event-stream"}
295
+
296
+ async with session.post(
297
+ "/v1/chat/completions", json=payload, headers=headers
298
+ ) as resp:
299
+ async for raw in resp.content:
300
+ resp.raise_for_status()
301
+ # extract new content and stream it
302
+ line = raw.decode().strip()
303
+ if not line or line == "data: [DONE]":
304
+ continue
305
+ if line.startswith("data: "): # SSE prefix
306
+ line = line[len("data: ") :]
307
+
308
+ chunk = json.loads(line)
309
+ assert (
310
+ chunk["object"] == "chat.completion.chunk"
311
+ ) # or something went horribly wrong
312
+ delta = chunk["choices"][0]["delta"]
313
+ content = (
314
+ delta.get("content")
315
+ or delta.get("reasoning")
316
+ or delta.get("reasoning_content")
317
+ )
318
+ if content:
319
+ print(content, end="")
320
+ else:
321
+ print("\n", chunk)
322
+ print()
323
+
324
+
325
+ # We also include a basic example of a load-testing setup using
326
+ # `locust` in the `load_test.py` script [here](https://github.com/modal-labs/modal-examples/tree/main/06_gpu_and_ml/llm-serving/openai_compatible):
327
+
328
+ # ```bash
329
+ # modal run openai_compatible/load_test.py
330
+ # ```