adityaverma977 commited on
Commit
43d708a
·
0 Parent(s):

inital build

Browse files
.gitignore ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+ SPEC.md
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+ backend/.env
35
+ .env
36
+ # Installer logs
37
+ pip-log.txt
38
+ pip-delete-this-directory.txt
39
+
40
+ # Unit test / coverage reports
41
+ htmlcov/
42
+ .tox/
43
+ .nox/
44
+ .coverage
45
+ .coverage.*
46
+ .cache
47
+ nosetests.xml
48
+ coverage.xml
49
+ *.cover
50
+ *.py.cover
51
+ .hypothesis/
52
+ .pytest_cache/
53
+ cover/
54
+
55
+ # Translations
56
+ *.mo
57
+ *.pot
58
+
59
+ # Django stuff:
60
+ *.log
61
+ local_settings.py
62
+ db.sqlite3
63
+ db.sqlite3-journal
64
+
65
+ # Flask stuff:
66
+ instance/
67
+ .webassets-cache
68
+
69
+ # Scrapy stuff:
70
+ .scrapy
71
+
72
+ # Sphinx documentation
73
+ docs/_build/
74
+
75
+ # PyBuilder
76
+ .pybuilder/
77
+ target/
78
+
79
+ # Jupyter Notebook
80
+ .ipynb_checkpoints
81
+
82
+ # IPython
83
+ profile_default/
84
+ ipython_config.py
85
+
86
+ # pyenv
87
+ # For a library or package, you might want to ignore these files since the code is
88
+ # intended to run in multiple environments; otherwise, check them in:
89
+ # .python-version
90
+
91
+ # pipenv
92
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
93
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
94
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
95
+ # install all needed dependencies.
96
+ # Pipfile.lock
97
+
98
+ # UV
99
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
100
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
101
+ # commonly ignored for libraries.
102
+ # uv.lock
103
+
104
+ # poetry
105
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
106
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
107
+ # commonly ignored for libraries.
108
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
109
+ # poetry.lock
110
+ # poetry.toml
111
+
112
+ # pdm
113
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
114
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
115
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
116
+ # pdm.lock
117
+ # pdm.toml
118
+ .pdm-python
119
+ .pdm-build/
120
+
121
+ # pixi
122
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
123
+ # pixi.lock
124
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
125
+ # in the .venv directory. It is recommended not to include this directory in version control.
126
+ .pixi
127
+
128
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
129
+ __pypackages__/
130
+
131
+ # Celery stuff
132
+ celerybeat-schedule
133
+ celerybeat.pid
134
+
135
+ # Redis
136
+ *.rdb
137
+ *.aof
138
+ *.pid
139
+
140
+ # RabbitMQ
141
+ mnesia/
142
+ rabbitmq/
143
+ rabbitmq-data/
144
+
145
+ # ActiveMQ
146
+ activemq-data/
147
+
148
+ # SageMath parsed files
149
+ *.sage.py
150
+
151
+ # Environments
152
+ .env
153
+ .envrc
154
+ .venv
155
+ env/
156
+ venv/
157
+ ENV/
158
+ env.bak/
159
+ venv.bak/
160
+
161
+ # Spyder project settings
162
+ .spyderproject
163
+ .spyproject
164
+
165
+ # Rope project settings
166
+ .ropeproject
167
+
168
+ # mkdocs documentation
169
+ /site
170
+
171
+ # mypy
172
+ .mypy_cache/
173
+ .dmypy.json
174
+ dmypy.json
175
+
176
+ # Pyre type checker
177
+ .pyre/
178
+
179
+ # pytype static type analyzer
180
+ .pytype/
181
+
182
+ # Cython debug symbols
183
+ cython_debug/
184
+
185
+ # PyCharm
186
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
187
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
188
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
189
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
190
+ # .idea/
191
+
192
+ # Abstra
193
+ # Abstra is an AI-powered process automation framework.
194
+ # Ignore directories containing user credentials, local state, and settings.
195
+ # Learn more at https://abstra.io/docs
196
+ .abstra/
197
+
198
+ # Visual Studio Code
199
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
200
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
201
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
202
+ # you could uncomment the following to ignore the entire vscode folder
203
+ # .vscode/
204
+ # Temporary file for partial code execution
205
+ tempCodeRunnerFile.py
206
+
207
+ # Ruff stuff:
208
+ .ruff_cache/
209
+
210
+ # PyPI configuration file
211
+ .pypirc
212
+
213
+ # Marimo
214
+ marimo/_static/
215
+ marimo/_lsp/
216
+ __marimo__/
217
+
218
+ # Streamlit
219
+ .streamlit/secrets.toml
QUICKSTART.md ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # RUSH AGENTS - Quick Start Guide
2
+
3
+ ## 📋 Overview
4
+
5
+ **RUSH AGENTS** is an AI model battle arena where models compete for survival by escaping lava and forming strategic alliances.
6
+
7
+ - **Intelligence Test**: See which AI model survives the longest
8
+ - **Alliance System**: Models can propose partnerships (but proposing signals weakness)
9
+ - **Rules-Based**: No personalities—all models play by identical rules
10
+ - **Groq API**: Fast, parallel decision-making for all models
11
+ - **Real-time**: WebSocket streaming, 3-second ticks
12
+
13
+ ---
14
+
15
+ ## 🚀 Quick Start (Local Development)
16
+
17
+ ### Prerequisites
18
+ ✅ Already installed:
19
+ - Python 3.10+
20
+ - Node.js 18+
21
+ - Groq API Key (in `backend/.env`)
22
+
23
+ ### Start Backend
24
+
25
+ ```bash
26
+ cd backend
27
+
28
+ # Verify .env has GROQ_API_KEY
29
+ cat .env
30
+
31
+ # Run the server
32
+ python -m uvicorn app.main:app --reload
33
+ ```
34
+
35
+ Server runs on: **http://localhost:8000**
36
+
37
+ ### Start Frontend (New Terminal)
38
+
39
+ ```bash
40
+ cd frontend
41
+
42
+ # Run dev server
43
+ npm run dev
44
+ ```
45
+
46
+ Frontend runs on: **http://localhost:3000**
47
+
48
+ ### Play
49
+
50
+ 1. Open http://localhost:3000
51
+ 2. Wait for "Ready for chaos"
52
+ 3. Select 2-6 models from the dropdown
53
+ 4. Click "Start Simulation"
54
+ 5. Click map to place volcano
55
+ 6. Watch the battle unfold!
56
+
57
+ ---
58
+
59
+ ## 📚 What's Available
60
+
61
+ ### Models
62
+ **Groq Models (Free Tier)**:
63
+ - `llama-3.1-8b-instant` — 8B fast
64
+ - `llama-3.1-70b-versatile` — 70B smart
65
+ - `mixtral-8x7b-32768` — 8x7B expert
66
+ - `gemma-7b-it` — 7B instruction-tuned
67
+
68
+ **HuggingFace Spaces** (Can add more):
69
+ - Falcon-7B
70
+ - Llama-2-7B
71
+ - Mistral-7B
72
+ - Zephyr-7B
73
+ - OpenHermes-7B
74
+
75
+ ---
76
+
77
+ ## 🎮 Game Rules
78
+
79
+ ### Each Model Sees:
80
+ - All other models' positions (x, y)
81
+ - Volcano position & radius
82
+ - Map bounds (1200x800)
83
+ - Distance to lava edge
84
+ - Alliance status (who's allied with whom)
85
+
86
+ ### Each Tick (3 seconds):
87
+
88
+ **1. Groq Decision Call** (Parallel for all models)
89
+ - "run" → Sprint away from lava
90
+ - "propose_alliance" → Ask another model to team up
91
+
92
+ **2. Alliance Processing**
93
+ - If A proposes to B, B gets asked immediately
94
+ - If B accepts → Both merge at same position
95
+ - If B rejects → A keeps running, B keeps running
96
+
97
+ **3. Movement**
98
+ - Models move based on decisions
99
+ - Position clamped to map bounds
100
+
101
+ **4. Lava Expands**
102
+ - Radius grows by 120 pixels
103
+
104
+ **5. Deaths**
105
+ - Models in lava radius die
106
+ - EXCEPT if they're stacked with an alive ally
107
+
108
+ **6. Win Check**
109
+ - ≤1 model alive → Game Over
110
+
111
+ ### Strategic Cost of Alliances
112
+ - **Proposing** = signaling weakness
113
+ - **First to propose** = lose strategic leverage
114
+ - **Benefit** = survival through numbers
115
+
116
+ ---
117
+
118
+ ## 🔑 Environment Variables
119
+
120
+ ### Backend (.env)
121
+ ```
122
+ GROQ_API_KEY=<your key here> # REQUIRED
123
+ HUGGINGFACE_API_TOKEN= # Optional
124
+ ALLOWED_ORIGINS=http://localhost:3000
125
+ ENV=development
126
+ ```
127
+
128
+ ### Frontend (.env.local)
129
+ ```
130
+ NEXT_PUBLIC_BACKEND_URL=http://localhost:8000
131
+ ```
132
+
133
+ ---
134
+
135
+ ## 📡 API Endpoints
136
+
137
+ ```
138
+ GET /wake → Health check + status
139
+ GET /available-models → List of all available models
140
+ POST /start-simulation → Create new simulation
141
+ POST /place-volcano → Place volcano, start ticking
142
+ GET /ws/{simulation_id} → WebSocket stream
143
+ ```
144
+
145
+ ---
146
+
147
+ ## 🧠 How Groq Decision-Making Works
148
+
149
+ ### Per Model, Per Tick:
150
+ 1. **Build state summary**
151
+ - Current standings (distance from lava)
152
+ - All agents' positions
153
+ - Volcano position & radius
154
+
155
+ 2. **Send to Groq Llama 3.1 8B**
156
+ ```json
157
+ {
158
+ "system": "You are [ModelName]. Make a strategic decision...",
159
+ "user": "[Game state]"
160
+ }
161
+ ```
162
+
163
+ 3. **Get response**
164
+ ```json
165
+ {
166
+ "action": "run|propose_alliance",
167
+ "alliance_target": "ModelName or null",
168
+ "reasoning": "..."
169
+ }
170
+ ```
171
+
172
+ 4. **Execute action**
173
+
174
+ All models queried in parallel (async) = fast ticks
175
+
176
+ ---
177
+
178
+ ## 🎨 Frontend Features
179
+
180
+ ### Map Canvas
181
+ - **White circles** = Individual models
182
+ - **Yellow glowing circles** = Stacked alliances (2+ models)
183
+ - **Orange/red glow** = Lava expanding
184
+ - **Gray skull** = Dead models
185
+ - **Yellow 🤝** = Alliance indicator on label
186
+
187
+ ### Sidebar
188
+ - **Model Selector** = Pick 2-6 models (groups by Groq/HF)
189
+ - **Chat Feed** = Events (alliances, deaths, decisions)
190
+ - **Status** = Current round, agents alive
191
+
192
+ ---
193
+
194
+ ## 🔧 Troubleshooting
195
+
196
+ ### "Failed to fetch models"
197
+ - Check backend is running: `python -m uvicorn app.main:app --reload`
198
+ - Check port 8000 is available
199
+
200
+ ### "Groq API error"
201
+ - Verify `GROQ_API_KEY` in `backend/.env`
202
+ - Check Groq dashboard: https://console.groq.com
203
+
204
+ ### WebSocket connection fails
205
+ - Frontend tries `ws://localhost:8000` (port 8000)
206
+ - Make sure backend is running
207
+
208
+ ### No models showing up
209
+ - Check `/available-models` returns data: `curl http://localhost:8000/available-models`
210
+
211
+ ---
212
+
213
+ ## 📝 Key Files
214
+
215
+ ```
216
+ backend/
217
+ app/
218
+ main.py → FastAPI server
219
+ simulation.py → Game engine logic
220
+ models.py → Pydantic schemas
221
+ groq_client.py → Groq API integration
222
+ hf_spaces.py → HF model discovery
223
+ movement.py → Physics/movement
224
+ .env → API keys (REQUIRED)
225
+ requirements.txt → Python dependencies
226
+
227
+ frontend/
228
+ app/
229
+ page.tsx → Main app component
230
+ components/
231
+ MapCanvas.tsx → 2D map rendering
232
+ ModelSelector.tsx → Model selection UI
233
+ ChatFeed.tsx → Event stream display
234
+ lib/
235
+ api.ts → Backend API calls
236
+ ```
237
+
238
+ ---
239
+
240
+ ## 🚢 Deployment
241
+
242
+ ### Backend (Railway, Heroku, etc.)
243
+ ```bash
244
+ # Set env vars
245
+ GROQ_API_KEY=...
246
+ ALLOWED_ORIGINS=https://yourdomain.com
247
+
248
+ # Deploy
249
+ gunicorn -w 4 -k uvicorn.workers.UvicornWorker app.main:app
250
+ ```
251
+
252
+ ### Frontend (Vercel, Netlify)
253
+ ```bash
254
+ npm run build
255
+ # Deploy the .next folder
256
+ ```
257
+
258
+ ---
259
+
260
+ ## 📖 Learn More
261
+
262
+ - Groq API docs: https://console.groq.com/docs
263
+ - FastAPI: https://fastapi.tiangolo.com/
264
+ - Next.js: https://nextjs.org/docs
265
+ - WebSocket: https://mdn.io/WebSocket
266
+
267
+ ---
268
+
269
+ ## 💡 Ideas for Experimentation
270
+
271
+ 1. **Add new models** — Edit `hf_spaces.py` KNOWN_SPACES_MODELS
272
+ 2. **Change game rules** — Edit `simulation.py` tick logic
273
+ 3. **New scenarios** — Add earthquake, meteor, etc. in `simulation.py`
274
+ 4. **Leaderboard** — Track best survival times across sessions
275
+ 5. **Replay system** — Save/load simulation events
276
+ 6. **Model attack mechanic** — Add "push" action to shove others toward lava
277
+
278
+ ---
279
+
280
+ **Ready to battle?** Open http://localhost:3000! 🌋
README.md ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # RUSH AGENTS RUSH
2
+
3
+ > *Different models. One fire. Real teamwork or chaos.*
4
+
5
+ Rush Agents Rush is a 2D multi-agent simulation where real AI models are dropped onto a map, a fire is placed, and the models must cooperate to survive and extinguish it. They vote for a leader, search for water, move as a coalition, and try to put the fire out before it consumes the map.
6
+
7
+ The current design has fully moved on from the old volcano/lava loop. The game is now about fire suppression, coalition strategy, and visible agent coordination.
8
+
9
+ ## Core Idea
10
+
11
+ 1. Pick 2-6 models from Groq or supported Hugging Face sources.
12
+ 2. Start a simulation and click the map to place the fire.
13
+ 3. Agents decide each tick whether to search water, collect water, extinguish fire, escape, or vote for a leader.
14
+ 4. Fire grows over time, but water collection and extinguishing reduce intensity.
15
+ 5. The game ends when the fire is fully extinguished or the agents are wiped out.
16
+
17
+ ## Features
18
+
19
+ - Real model names and visible per-agent positions on the map
20
+ - Coalition voting and leader election
21
+ - Water wells placed around the fire arena
22
+ - Fire growth and deterministic extinguishing logic
23
+ - WebSocket-driven live simulation updates
24
+ - Chat feed with varied team-style messages
25
+ - End-of-game winner text showing the strongest extinguisher
26
+
27
+ ## Tech Stack
28
+
29
+ | Layer | Tech |
30
+ | --- | --- |
31
+ | Frontend | Next.js 16, React 19, TypeScript, Tailwind CSS v4 |
32
+ | Backend | FastAPI, Python, Uvicorn |
33
+ | AI | Groq models, optional Hugging Face-backed model discovery |
34
+ | Realtime | WebSockets |
35
+
36
+ ## Repository Layout
37
+
38
+ - [backend](backend): FastAPI API, simulation engine, model state, WebSocket streaming
39
+ - [frontend](frontend): Next.js UI, map rendering, chat feed, model selection
40
+
41
+ ## Local Setup
42
+
43
+ ### Backend
44
+
45
+ ```bash
46
+ cd backend
47
+ pip install -r requirements.txt
48
+ python -m uvicorn app.main:app --reload --port 8000
49
+ ```
50
+
51
+ ### Frontend
52
+
53
+ ```bash
54
+ cd frontend
55
+ npm install
56
+ npm run dev
57
+ ```
58
+
59
+ Then open http://localhost:3000.
60
+
61
+ ## Environment Variables
62
+
63
+ ### Backend
64
+
65
+ ```env
66
+ GROQ_API_KEY=your_groq_key
67
+ ALLOWED_ORIGINS=http://localhost:3000
68
+ ```
69
+
70
+ ### Frontend
71
+
72
+ ```env
73
+ NEXT_PUBLIC_BACKEND_URL=http://localhost:8000
74
+ ```
75
+
76
+ ## API Overview
77
+
78
+ | Method | Path | Purpose |
79
+ | --- | --- | --- |
80
+ | GET | /wake | Health and readiness check |
81
+ | GET | /available-models | List available models for the UI |
82
+ | POST | /start-simulation | Create a new simulation |
83
+ | POST | /place-fire | Place the fire and generate water wells |
84
+ | WS | /ws/{simulation_id} | Stream simulation ticks and events |
85
+
86
+ ## Simulation Loop
87
+
88
+ Each tick the backend:
89
+
90
+ 1. Collects decisions from all living agents in parallel.
91
+ 2. Runs coalition voting if a leader has not been chosen.
92
+ 3. Moves agents toward water or the fire edge based on their current action.
93
+ 4. Grows the fire slightly.
94
+ 5. Applies deterministic extinguish damage based on how many agents are actually in position.
95
+ 6. Removes agents caught inside the fire radius.
96
+ 7. Ends the game when the fire is out or only one agent remains.
97
+
98
+ ## Notes
99
+
100
+ - State is kept in memory, so simulations reset when the backend restarts.
101
+ - The UI is designed around visible cooperation, not just survival.
102
+ - Old lava/volcano docs are intentionally replaced by the fire/water scenario.
103
+
backend/.env.example ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Backend API Configuration
2
+ GROQ_API_KEY=your_groq_api_key_here
3
+
4
+ # Optional: HuggingFace API token for accessing HF Spaces models
5
+ HUGGINGFACE_API_TOKEN=your_huggingface_token_here
6
+
7
+ # CORS allowed origins (comma-separated)
8
+ ALLOWED_ORIGINS=http://localhost:3000,https://yourdomain.com
9
+
10
+ # Optional: Backend port
11
+ BACKEND_PORT=8000
12
+
13
+ # Optional: Environment
14
+ ENV=development
backend/.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.14
backend/Dockerfile ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+ WORKDIR /app
3
+ COPY requirements.txt .
4
+ RUN pip install --no-cache-dir -r requirements.txt
5
+ COPY app/ ./app/
6
+ EXPOSE 7860
7
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
backend/README.md ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: RUSH AGENTS RUSH Backend
3
+ emoji: 🔥
4
+ colorFrom: orange
5
+ colorTo: red
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ # Rush Agents Rush Backend
11
+
12
+ FastAPI server driving the fire-suppression simulation.
13
+
14
+ ## What It Does
15
+
16
+ - Accepts model selections and starts a new simulation.
17
+ - Places a fire on the map and generates water wells.
18
+ - Runs the tick-based AI loop with coalition voting, movement, and extinguishing.
19
+ - Streams state updates and events over WebSockets.
20
+
21
+ ## Key Endpoints
22
+
23
+ - `GET /wake` - health and readiness check
24
+ - `GET /available-models` - list available models for the UI
25
+ - `POST /start-simulation` - create a new simulation
26
+ - `POST /place-fire` - place the fire and spawn water sources
27
+ - `WS /ws/{simulation_id}` - stream live simulation ticks
28
+
29
+ ## Environment Variables
30
+
31
+ - `GROQ_API_KEY`: Required for agent decisions.
32
+ - `ALLOWED_ORIGINS`: CORS whitelist.
33
+
34
+ ## Local Run
35
+
36
+ ```bash
37
+ cd backend
38
+ pip install -r requirements.txt
39
+ python -m uvicorn app.main:app --reload --port 8000
40
+ ```
41
+
42
+ ## Notes
43
+
44
+ - Simulation state is in memory.
45
+ - Fire growth, extinguish rate, and movement are tuned in `app/simulation.py`.
46
+ - Model decisions are generated in `app/groq_client.py`.
backend/app/__init__.py ADDED
File without changes
backend/app/groq_client.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import random
4
+ import math
5
+ from groq import AsyncGroq
6
+ from dotenv import load_dotenv
7
+
8
+ load_dotenv()
9
+
10
+ _GROQ_API_KEY = os.environ.get("GROQ_API_KEY")
11
+ _client = AsyncGroq(api_key=_GROQ_API_KEY) if _GROQ_API_KEY else None
12
+
13
+ DEFAULT_DECISION_MODEL = "llama-3.1-8b-instant"
14
+ MAX_AGENT_SPEED = 80
15
+
16
+
17
+ def is_ready():
18
+ return _client is not None
19
+
20
+
21
+ def _build_fire_state_summary(agent, fire, all_agents) -> str:
22
+ """Build a state summary for the fire scenario."""
23
+ standings = []
24
+ for a in all_agents:
25
+ if not a.alive:
26
+ continue
27
+ dist = math.dist((a.x, a.y), (fire.x, fire.y))
28
+ standings.append({
29
+ "name": a.display_name,
30
+ "model": a.model_name,
31
+ "distance_from_fire": dist,
32
+ "x": a.x,
33
+ "y": a.y,
34
+ "has_water": a.water_collected,
35
+ "mode": a.mode,
36
+ })
37
+
38
+ standings.sort(key=lambda s: s['distance_from_fire'])
39
+
40
+ lines = ["Current standings:"]
41
+ for rank, s in enumerate(standings, 1):
42
+ water_str = " (carrying water)" if s['has_water'] else ""
43
+ lines.append(f" #{rank} {s['name']}: {s['distance_from_fire']:.0f}px from fire{water_str}")
44
+
45
+ return "\n".join(lines)
46
+
47
+
48
+ async def generate_fire_decision(agent, fire, water_sources, other_agents, bounds) -> dict:
49
+ """
50
+ Fire scenario decision system.
51
+ Actions: search_water, collect_water, extinguish_fire, escape, vote_for_leader
52
+ """
53
+ if not _client:
54
+ return _fallback_escape(agent, fire)
55
+
56
+ dist_to_fire = math.dist((agent.x, agent.y), (fire.x, fire.y))
57
+ nearest_water = min(water_sources, key=lambda w: math.dist((agent.x, agent.y), (w.x, w.y))) if water_sources else None
58
+ dist_to_water = math.dist((agent.x, agent.y), (nearest_water.x, nearest_water.y)) if nearest_water else None
59
+
60
+ living_agents = [a for a in other_agents if a.alive and a.model_name != agent.model_name]
61
+ state_summary = _build_fire_state_summary(agent, fire, [agent] + living_agents)
62
+
63
+ coalition_leader = next((a.model_name for a in other_agents if a.is_leader), None)
64
+ dist_to_water_display = f"{dist_to_water:.0f}px" if dist_to_water is not None else "unknown"
65
+ system_prompt = f"""You are {agent.model_name}, an AI model in a critical wildfire survival scenario.
66
+
67
+ THE SCENARIO:
68
+ - A wildfire is spreading rapidly across the map
69
+ - Water sources (wells) are scattered around the area
70
+ - You can work alone or join a coalition with other AI models
71
+ - Coalition agents should elect a leader who coordinates the strategy
72
+ - If a leader exists, follow their plan: gather water, then move to the fire edge to extinguish
73
+ - To win: Find water → Collect it → Return to fire → Extinguish it together (or solo)
74
+ - If the fire consumes you, you lose
75
+
76
+ YOUR STRATEGIC OPTIONS EACH TICK:
77
+ 1. "search_water" - Move toward the nearest water source
78
+ 2. "collect_water" - Pick up water from a well (must be at a source)
79
+ 3. "extinguish_fire" - Use collected water to fight the fire (must have water)
80
+ 4. "escape" - Run away from the fire to survive
81
+ 5. "vote_for_leader" - Vote for yourself or another model as coalition leader
82
+
83
+ IMPORTANT CONSIDERATIONS:
84
+ - If fire is very close (< 200px), prioritize escape or finding water
85
+ - If you have water, move to the fire edge and extinguish
86
+ - If you are near a water source (< 60px), collect it immediately
87
+ - Coalition mode requires coordination; vote strategically
88
+ - Solo mode means you act independently and don't wait for others
89
+
90
+ OUTPUT FORMAT - return ONLY valid JSON:
91
+ {{"action": "<search_water|collect_water|extinguish_fire|escape|vote_for_leader>", "vote_for": "<model_name if voting, else null>", "message": "<full English sentence>", "reasoning": "<one sentence>"}}
92
+
93
+ CURRENT STATE:
94
+ Your position: ({agent.x}, {agent.y})
95
+ Fire position: ({fire.x}, {fire.y})
96
+ Distance from fire: {dist_to_fire:.0f}px
97
+ Fire radius: {fire.radius:.0f}px
98
+ Fire intensity: {fire.intensity:.0f}%
99
+ Carrying water: {agent.water_collected}
100
+ Mode: {agent.mode} ({'joined a coalition' if agent.mode == 'coalition' else 'acting alone'})
101
+ Nearest water distance: {dist_to_water_display}
102
+ Coalition leader: {coalition_leader or 'none'}
103
+
104
+ {state_summary}
105
+
106
+ What do you do?"""
107
+
108
+ try:
109
+ completion = await _client.chat.completions.create(
110
+ model=DEFAULT_DECISION_MODEL,
111
+ messages=[
112
+ {"role": "system", "content": system_prompt},
113
+ {"role": "user", "content": "Make your decision."}
114
+ ],
115
+ response_format={"type": "json_object"},
116
+ max_tokens=150,
117
+ timeout=3.0
118
+ )
119
+ decision = json.loads(completion.choices[0].message.content)
120
+
121
+ action = decision.get("action", "escape")
122
+ if action not in ["search_water", "collect_water", "extinguish_fire", "escape", "vote_for_leader"]:
123
+ action = "escape"
124
+
125
+ if dist_to_water is not None and dist_to_water <= 60 and not agent.water_collected:
126
+ action = "collect_water"
127
+ elif agent.water_collected and dist_to_fire <= 350:
128
+ action = "extinguish_fire"
129
+
130
+ return {
131
+ "action": action,
132
+ "vote_for": decision.get("vote_for"),
133
+ "message": decision.get("message", "Moving strategically."),
134
+ "reasoning": decision.get("reasoning", "Survival and teamwork.")
135
+ }
136
+ except Exception as e:
137
+ print(f"Error calling groq for {agent.model_name}: {e}")
138
+ return _fallback_escape(agent, fire)
139
+
140
+
141
+ def _fallback_escape(agent, fire) -> dict:
142
+ """Fallback escape behavior."""
143
+ dx = agent.x - fire.x
144
+ dy = agent.y - fire.y
145
+ dist = math.sqrt(dx**2 + dy**2) or 1
146
+ return {
147
+ "message": "Running to safety!",
148
+ "action": "escape",
149
+ "vote_for": None,
150
+ "reasoning": "Fallback: survive."
151
+ }
backend/app/hf_spaces.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HuggingFace Spaces integration for discovering and querying open-source models.
3
+ """
4
+ import os
5
+ import httpx
6
+ from typing import Optional
7
+
8
+ HF_API_TOKEN = os.environ.get("HUGGINGFACE_API_TOKEN", "")
9
+
10
+ # Curated list of verified open-source models on HF Spaces that work reliably
11
+ KNOWN_SPACES_MODELS = [
12
+ {
13
+ "id": "tiiuae/Falcon-7B",
14
+ "name": "Falcon-7B",
15
+ "space_url": "https://huggingface.co/spaces/tiiuae/falcon-chat",
16
+ "description": "7B parameter open model",
17
+ },
18
+ {
19
+ "id": "meta-llama/Llama-2-7b",
20
+ "name": "Llama-2-7B",
21
+ "space_url": "https://huggingface.co/spaces/meta-llama/Llama-2-7b-chat",
22
+ "description": "Meta's 7B model",
23
+ },
24
+ {
25
+ "id": "mistralai/Mistral-7B",
26
+ "name": "Mistral-7B",
27
+ "space_url": "https://huggingface.co/spaces/mistralai/Mistral-7B-Instruct-v0.1",
28
+ "description": "Mistral's 7B model",
29
+ },
30
+ {
31
+ "id": "HuggingFaceH4/zephyr-7b",
32
+ "name": "Zephyr-7B",
33
+ "space_url": "https://huggingface.co/spaces/HuggingFaceH4/zephyr-7b-beta",
34
+ "description": "Zephyr 7B fine-tuned model",
35
+ },
36
+ {
37
+ "id": "teknium/OpenHermes-2.5-Mistral-7B",
38
+ "name": "OpenHermes-7B",
39
+ "space_url": "https://huggingface.co/spaces/teknium/OpenHermes-2.5-Mistral-7B",
40
+ "description": "OpenHermes instruction-tuned 7B",
41
+ },
42
+ ]
43
+
44
+ # Groq models (built-in)
45
+ GROQ_MODELS = [
46
+ {"id": "llama-3.1-8b-instant", "name": "Llama 3.1 8B", "backend": "groq"},
47
+ {"id": "llama-3.1-70b-versatile", "name": "Llama 3.1 70B", "backend": "groq"},
48
+ {"id": "mixtral-8x7b-32768", "name": "Mixtral 8x7B", "backend": "groq"},
49
+ {"id": "gemma-7b-it", "name": "Gemma 7B", "backend": "groq"},
50
+ ]
51
+
52
+
53
+ async def get_available_models() -> dict:
54
+ """
55
+ Get list of available models from Groq and HF Spaces.
56
+ Returns both for frontend model selector.
57
+ """
58
+ return {
59
+ "groq_models": GROQ_MODELS,
60
+ "hf_spaces_models": KNOWN_SPACES_MODELS,
61
+ "total": len(GROQ_MODELS) + len(KNOWN_SPACES_MODELS),
62
+ }
63
+
64
+
65
+ async def query_hf_space_model(model_id: str, prompt: str) -> Optional[str]:
66
+ """
67
+ Query a model on HuggingFace Spaces.
68
+ This is a fallback if we want to use HF spaces directly.
69
+ Note: HF spaces may have rate limits and require authentication.
70
+ """
71
+ if not HF_API_TOKEN:
72
+ return None
73
+
74
+ # Try to find the space URL for this model
75
+ space = next((m for m in KNOWN_SPACES_MODELS if m["id"] == model_id), None)
76
+ if not space:
77
+ return None
78
+
79
+ try:
80
+ # This would hit the HF inference API
81
+ # For now, we focus on Groq which is more reliable
82
+ async with httpx.AsyncClient(timeout=5.0) as client:
83
+ headers = {"Authorization": f"Bearer {HF_API_TOKEN}"}
84
+ response = await client.post(
85
+ "https://api-inference.huggingface.co/models/" + model_id,
86
+ json={"inputs": prompt},
87
+ headers=headers,
88
+ )
89
+ if response.status_code == 200:
90
+ result = response.json()
91
+ # Extract generated text from response
92
+ if isinstance(result, list) and len(result) > 0:
93
+ return result[0].get("generated_text", "")
94
+ except Exception as e:
95
+ print(f"Error querying HF space {model_id}: {e}")
96
+
97
+ return None
98
+
99
+
100
+ def get_model_display_name(model_id: str) -> str:
101
+ """Get a clean display name from model ID."""
102
+ # Try to find in known models
103
+ for model in GROQ_MODELS + KNOWN_SPACES_MODELS:
104
+ if model["id"] == model_id:
105
+ return model["name"]
106
+
107
+ # Fallback: clean up the ID
108
+ return model_id.split("/")[-1].split("-")[0].capitalize()
backend/app/main.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import math
4
+ import random
5
+ import uuid
6
+ import os
7
+ import time
8
+ from typing import Optional
9
+ from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from pydantic import BaseModel, Field
12
+ from dotenv import load_dotenv
13
+
14
+ load_dotenv()
15
+
16
+ from .models import SimulationState, AgentModel, TickResponse, FireScenario, WaterSource
17
+ from .simulation import SimulationEngine, TICK_INTERVAL_SECONDS
18
+ from . import groq_client
19
+ from . import hf_spaces
20
+
21
+ app = FastAPI(title="Unhinged 2.0", version="0.2.0")
22
+
23
+ ALLOWED_ORIGINS = os.environ.get(
24
+ "ALLOWED_ORIGINS",
25
+ "http://localhost:3000"
26
+ ).split(",")
27
+
28
+ app.add_middleware(
29
+ CORSMiddleware,
30
+ allow_origins=ALLOWED_ORIGINS,
31
+ allow_credentials=True,
32
+ allow_methods=["*"],
33
+ allow_headers=["*"],
34
+ )
35
+
36
+ active_simulations: dict[str, SimulationState] = {}
37
+ START_TIME = time.time()
38
+
39
+ class StartSimulationRequest(BaseModel):
40
+ model_names: list[str] = Field(..., min_length=2, max_length=6)
41
+ scenario: str = "fire"
42
+ map_width: int = 1200
43
+ map_height: int = 800
44
+
45
+ class StartSimulationResponse(BaseModel):
46
+ simulation_id: str
47
+ state: SimulationState
48
+
49
+ class PlaceFireRequest(BaseModel):
50
+ simulation_id: str
51
+ x: int
52
+ y: int
53
+
54
+ class TickRequest(BaseModel):
55
+ simulation_id: str
56
+
57
+ @app.get("/wake")
58
+ async def wake():
59
+ return {
60
+ "warm": True,
61
+ "groq_available": groq_client.is_ready(),
62
+ "uptime_seconds": int(time.time() - START_TIME),
63
+ }
64
+
65
+ @app.get("/available-models")
66
+ async def get_available_models():
67
+ """Get list of available models (Groq + HF Spaces) for the UI."""
68
+ return await hf_spaces.get_available_models()
69
+
70
+ @app.post("/start-simulation", response_model=StartSimulationResponse)
71
+ async def start_simulation(req: StartSimulationRequest):
72
+ if req.scenario != "fire":
73
+ raise HTTPException(status_code=400, detail="Only 'fire' scenario supported.")
74
+
75
+ agents = _spawn_agents(req.model_names, req.map_width, req.map_height)
76
+
77
+ state = SimulationState(
78
+ simulation_id=str(uuid.uuid4()),
79
+ scenario=req.scenario,
80
+ map_width=req.map_width,
81
+ map_height=req.map_height,
82
+ agents=agents,
83
+ fire=None,
84
+ water_sources=[],
85
+ round=0,
86
+ status="waiting_for_scenario",
87
+ )
88
+
89
+ active_simulations[state.simulation_id] = state
90
+ return StartSimulationResponse(simulation_id=state.simulation_id, state=state)
91
+
92
+ @app.post("/place-fire", response_model=SimulationState)
93
+ def place_fire(req: PlaceFireRequest):
94
+ sim = _get_or_404(req.simulation_id)
95
+ if sim.status != "waiting_for_scenario":
96
+ raise HTTPException(status_code=409, detail="Fire already placed or simulation finished.")
97
+
98
+ # Create fire at clicked location
99
+ sim.fire = FireScenario(x=req.x, y=req.y)
100
+
101
+ # Generate 3-5 water sources scattered around the map
102
+ num_sources = random.randint(3, 5)
103
+ for i in range(num_sources):
104
+ water_x = random.randint(100, req.x - 200) if req.x > 200 else random.randint(0, 400)
105
+ if random.random() > 0.5:
106
+ water_x = random.randint(req.x + 200, sim.map_width - 100) if req.x < sim.map_width - 200 else random.randint(sim.map_width - 400, sim.map_width)
107
+ water_y = random.randint(100, sim.map_height - 100)
108
+ sim.water_sources.append(WaterSource(id=f"water_{i}", x=water_x, y=water_y))
109
+
110
+ sim.status = "running"
111
+ return sim
112
+
113
+ @app.websocket("/ws/{simulation_id}")
114
+ async def simulation_ws(websocket: WebSocket, simulation_id: str):
115
+ await websocket.accept()
116
+ sim = active_simulations.get(simulation_id)
117
+ if not sim:
118
+ await websocket.close(code=1008)
119
+ return
120
+
121
+ try:
122
+ while True:
123
+ if sim.status == "waiting_for_scenario":
124
+ await asyncio.sleep(1)
125
+ continue
126
+
127
+ if sim.status == "finished":
128
+ await websocket.send_json({"type": "finished", "state": sim.model_dump()})
129
+ await websocket.close(code=1000)
130
+ return
131
+
132
+ engine = SimulationEngine(sim)
133
+ result = await engine.tick()
134
+ active_simulations[simulation_id] = result.state
135
+
136
+ # DEBUG: log outgoing TickResponse summary for troubleshooting
137
+ try:
138
+ agent_states = [(a.model_name, a.alive) for a in result.state.agents]
139
+ except Exception:
140
+ agent_states = str(result.state)
141
+ print(f"WS_SEND sim={simulation_id} round={result.round} agents={agent_states} events={len(result.events)}")
142
+
143
+ await websocket.send_json(result.model_dump())
144
+
145
+ if result.state.status == "finished":
146
+ await websocket.close(code=1000)
147
+ return
148
+
149
+ await asyncio.sleep(TICK_INTERVAL_SECONDS)
150
+
151
+ except WebSocketDisconnect:
152
+ pass
153
+
154
+ def _spawn_agents(model_names: list[str], width: int, height: int) -> list[AgentModel]:
155
+ min_gap = 100
156
+ positions = []
157
+ agents = []
158
+ for name in model_names:
159
+ for _ in range(100):
160
+ x = random.randint(100, width - 100)
161
+ y = random.randint(100, height - 100)
162
+ if all(math.dist((x, y), p) >= min_gap for p in positions):
163
+ positions.append((x, y))
164
+ break
165
+ else:
166
+ positions.append((x, y))
167
+
168
+ agents.append(AgentModel(
169
+ model_name=name,
170
+ display_name=name.split("/")[-1].split("-")[0].capitalize(),
171
+ x=positions[-1][0],
172
+ y=positions[-1][1],
173
+ alive=True
174
+ ))
175
+ return agents
176
+
177
+ def _get_or_404(simulation_id: str) -> SimulationState:
178
+ sim = active_simulations.get(simulation_id)
179
+ if not sim:
180
+ raise HTTPException(status_code=404, detail="Simulation not found")
181
+ return sim
backend/app/models.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Literal, Optional, Union
2
+ from pydantic import BaseModel
3
+
4
+
5
+ class AgentModel(BaseModel):
6
+ model_name: str # full id, e.g. "llama-3.1-8b-instant"
7
+ display_name: str # short label shown on map
8
+ x: int
9
+ y: int
10
+ alive: bool = True
11
+ allied_with: Optional[str] = None # model_name of ally (if stacked)
12
+ has_proposed_alliance: bool = False
13
+ last_message: Optional[str] = None
14
+ distance_to_fire: Optional[float] = None
15
+ # New fields for fire/coalition mechanics
16
+ water_collected: bool = False # carrying water
17
+ is_leader: bool = False # elected coalition leader
18
+ coalition_members: list[str] = [] # list of allied agent model_names
19
+ mode: Literal["solo", "coalition"] = "coalition" # agent's chosen path
20
+ status: Literal["searching", "collecting_water", "extinguishing_fire", "escaping", "idle"] = "idle"
21
+ vote_for: Optional[str] = None # who this agent voted for as leader
22
+ extinguish_score: float = 0.0 # total fire intensity reduced by this agent
23
+
24
+
25
+ class FireScenario(BaseModel):
26
+ x: int
27
+ y: int
28
+ radius: float = 50.0 # current fire radius
29
+ intensity: float = 100.0 # 0-100; when 0, fire is out
30
+ growth_rate: float = 3.0 # px per tick
31
+
32
+
33
+ class WaterSource(BaseModel):
34
+ id: str # unique id
35
+ x: int
36
+ y: int
37
+ water_amount: float = 50.0 # how much water available
38
+
39
+
40
+ class SimulationState(BaseModel):
41
+ simulation_id: str
42
+ scenario: str # "fire" (was "volcano")
43
+ map_width: int
44
+ map_height: int
45
+ agents: list[AgentModel]
46
+ fire: Optional[FireScenario] = None
47
+ water_sources: list[WaterSource] = []
48
+ round: int = 0
49
+ status: str = "waiting_for_scenario"
50
+ winner_model: Optional[str] = None
51
+ coalition_leader: Optional[str] = None # elected leader
52
+ coalition_members: list[str] = [] # all coalition members
53
+
54
+
55
+ # Event models
56
+ class DeathEvent(BaseModel):
57
+ type: Literal["death"] = "death"
58
+ model: str
59
+
60
+ class MessageEvent(BaseModel):
61
+ type: Literal["message"] = "message"
62
+ model: str
63
+ content: str
64
+
65
+ class AllianceProposalEvent(BaseModel):
66
+ type: Literal["alliance_proposal"] = "alliance_proposal"
67
+ from_model: str
68
+ to_model: str
69
+
70
+ class AllianceAcceptEvent(BaseModel):
71
+ type: Literal["alliance_accept"] = "alliance_accept"
72
+ model_a: str
73
+ model_b: str
74
+ stacked: bool = True
75
+
76
+ class AllianceRejectEvent(BaseModel):
77
+ type: Literal["alliance_reject"] = "alliance_reject"
78
+ from_model: str
79
+ to_model: str
80
+
81
+ class LeadershipVoteEvent(BaseModel):
82
+ type: Literal["leadership_vote"] = "leadership_vote"
83
+ voter: str
84
+ candidate: str
85
+
86
+ class LeaderElectedEvent(BaseModel):
87
+ type: Literal["leader_elected"] = "leader_elected"
88
+ leader: str
89
+ coalition_members: list[str]
90
+
91
+ class WaterCollectedEvent(BaseModel):
92
+ type: Literal["water_collected"] = "water_collected"
93
+ model: str
94
+ water_source_id: str
95
+
96
+ class FireExtinguishedEvent(BaseModel):
97
+ type: Literal["fire_extinguished"] = "fire_extinguished"
98
+ extinguished_by: list[str] # models that contributed
99
+ fire_intensity: float
100
+
101
+ class FireSpreadEvent(BaseModel):
102
+ type: Literal["fire_spread"] = "fire_spread"
103
+ new_radius: float
104
+ new_intensity: float
105
+
106
+
107
+ class ChatEntry(BaseModel):
108
+ agent_id: str
109
+ message: str
110
+ tick: int
111
+
112
+
113
+ class TickResponse(BaseModel):
114
+ simulation_id: str
115
+ round: int
116
+ events: list[Union[DeathEvent, MessageEvent, AllianceProposalEvent, AllianceAcceptEvent,
117
+ AllianceRejectEvent, LeadershipVoteEvent, LeaderElectedEvent,
118
+ WaterCollectedEvent, FireExtinguishedEvent, FireSpreadEvent]]
119
+ chat: list[ChatEntry]
120
+ state: SimulationState
backend/app/movement.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+
3
+ MAX_AGENT_SPEED = 80
4
+
5
+ def apply_movement(agent, dx: int, dy: int, bounds: tuple) -> tuple[int, int]:
6
+ # 1. Clamp dx/dy to [-MAX_AGENT_SPEED, MAX_AGENT_SPEED]
7
+ dx = max(-MAX_AGENT_SPEED, min(MAX_AGENT_SPEED, dx))
8
+ dy = max(-MAX_AGENT_SPEED, min(MAX_AGENT_SPEED, dy))
9
+
10
+ # 2. Calculate new_x, new_y
11
+ new_x = agent.x + dx
12
+ new_y = agent.y + dy
13
+
14
+ # 3. Clamp to canvas bounds
15
+ new_x = max(0, min(new_x, bounds[0]))
16
+ new_y = max(0, min(new_y, bounds[1]))
17
+
18
+ # 4. Return (new_x, new_y)
19
+ return (int(new_x), int(new_y))
20
+
21
+ def is_in_lava(agent, volcano) -> bool:
22
+ if not volcano:
23
+ return False
24
+ return math.dist((agent.x, agent.y), (volcano.x, volcano.y)) <= volcano.radius
25
+
26
+ def distance_to_lava_edge(agent, volcano) -> float:
27
+ if not volcano:
28
+ return 1000.0
29
+ return math.dist((agent.x, agent.y), (volcano.x, volcano.y)) - volcano.radius
backend/app/personality.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ from . import groq_client
3
+
4
+ async def _fetch_model_card(model_name: str) -> str:
5
+ # We'll use a few specific models from Groq, so model card fetching
6
+ # might not always find a "README.md" on HF for these specific names
7
+ # if they are just the Groq IDs. But we'll try.
8
+ url = f"https://huggingface.co/{model_name}/raw/main/README.md"
9
+ try:
10
+ async with httpx.AsyncClient(timeout=5.0) as http:
11
+ response = await http.get(url)
12
+ if response.status_code == 200:
13
+ return response.text[:2000]
14
+ except Exception:
15
+ pass
16
+ return f"A powerful AI model known as {model_name}."
17
+
18
+ async def generate_personality(model_name: str) -> dict:
19
+ model_card = await _fetch_model_card(model_name)
20
+ return await groq_client.generate_personality(model_name, model_card)
backend/app/simulation.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import math
4
+ import random
5
+ from typing import Union
6
+
7
+ from .models import (
8
+ AgentModel,
9
+ DeathEvent,
10
+ MessageEvent,
11
+ LeadershipVoteEvent,
12
+ LeaderElectedEvent,
13
+ WaterCollectedEvent,
14
+ FireExtinguishedEvent,
15
+ FireSpreadEvent,
16
+ SimulationState,
17
+ TickResponse,
18
+ ChatEntry,
19
+ )
20
+ from . import groq_client
21
+ from . import movement
22
+
23
+ FIRE_GROWTH_RATE = 1.0 # radius growth per tick
24
+ FIRE_INTENSITY_GROWTH = 0.9 # intensity per tick
25
+ BASE_EXTINGUISH_RATE = 15.0 # baseline intensity reduction per agent
26
+ MIN_EXTINGUISH_RATE = 8.0
27
+ MAX_EXTINGUISH_RATE = 28.0
28
+ TICK_INTERVAL_SECONDS = 3
29
+ WATER_PICKUP_RANGE = 40
30
+ EXTINGUISH_RANGE = 45
31
+ FIRE_SAFE_BUFFER = 10
32
+
33
+ class SimulationEngine:
34
+ def __init__(self, state: SimulationState) -> None:
35
+ self.state = state
36
+
37
+ def _pick_message(self, action: str) -> str:
38
+ options = {
39
+ "search_water": [
40
+ "Scanning for the nearest well.",
41
+ "Heading toward water to stock up.",
42
+ "Tracking a water source now.",
43
+ "Moving to secure water for the team.",
44
+ ],
45
+ "collect_water": [
46
+ "Water secured. Moving to the fire line.",
47
+ "Got water. Heading to the fire edge.",
48
+ "Collected water. Time to extinguish.",
49
+ ],
50
+ "extinguish_fire": [
51
+ "Pouring water at the fire edge.",
52
+ "Holding position and dousing flames.",
53
+ "Suppressing the fire with water.",
54
+ ],
55
+ "escape": [
56
+ "Falling back to a safer position.",
57
+ "Repositioning away from the flames.",
58
+ "Retreating to avoid the fire front.",
59
+ ],
60
+ }
61
+ return random.choice(options.get(action, ["Moving strategically."]))
62
+
63
+ def _move_toward(self, agent: AgentModel, target_x: float, target_y: float, stop_distance: float = 0) -> None:
64
+ dx = target_x - agent.x
65
+ dy = target_y - agent.y
66
+ dist = math.sqrt(dx**2 + dy**2) or 1
67
+ if dist <= stop_distance:
68
+ return
69
+ step = min(movement.MAX_AGENT_SPEED, dist - stop_distance)
70
+ agent.x += int((dx / dist) * step)
71
+ agent.y += int((dy / dist) * step)
72
+ agent.x = max(0, min(agent.x, self.state.map_width))
73
+ agent.y = max(0, min(agent.y, self.state.map_height))
74
+
75
+ async def tick(self) -> TickResponse:
76
+ """
77
+ Main simulation loop:
78
+ 1. Get decisions from all living agents
79
+ 2. Handle coalition leadership voting
80
+ 3. Execute agent actions (search water, collect, extinguish, escape, etc.)
81
+ 4. Grow fire
82
+ 5. Extinguish fire if agents with water are present
83
+ 6. Kill agents in fire (but protect coalition members)
84
+ 7. Check win condition
85
+ """
86
+ if self.state.status != "running":
87
+ raise ValueError(f"Cannot tick a simulation with status '{self.state.status}'.")
88
+
89
+ fire = self.state.fire
90
+ assert fire is not None, "Fire must be placed before ticking."
91
+
92
+ events = []
93
+ bounds = (self.state.map_width, self.state.map_height)
94
+ living_agents = [a for a in self.state.agents if a.alive]
95
+
96
+ # 1. Get decisions from all living agents
97
+ decisions = await asyncio.gather(
98
+ *[groq_client.generate_fire_decision(agent, fire, self.state.water_sources, living_agents, bounds)
99
+ for agent in living_agents],
100
+ return_exceptions=True
101
+ )
102
+
103
+ decision_map = {}
104
+ for agent, decision in zip(living_agents, decisions):
105
+ if isinstance(decision, Exception):
106
+ decision = groq_client._fallback_escape(agent, fire)
107
+ decision_map[agent.model_name] = decision
108
+
109
+ # 2. Leadership voting phase (if coalition leader not elected)
110
+ if not self.state.coalition_leader:
111
+ vote_events = await self._voting_phase(living_agents, decision_map)
112
+ events.extend(vote_events)
113
+
114
+ # 3. Execute actions
115
+ action_events = await self._execute_actions(living_agents, decision_map, fire)
116
+ events.extend(action_events)
117
+
118
+ # 4. Grow fire
119
+ fire.radius += FIRE_GROWTH_RATE
120
+ fire.intensity += FIRE_INTENSITY_GROWTH
121
+ if fire.intensity > 100.0:
122
+ fire.intensity = 100.0
123
+
124
+ if fire.intensity > 0:
125
+ events.append(FireSpreadEvent(new_radius=fire.radius, new_intensity=fire.intensity))
126
+
127
+ # 5. Extinguish fire if agents with water are present
128
+ extinguish_events = self._check_extinguish(living_agents, fire)
129
+ events.extend(extinguish_events)
130
+
131
+ # 6. Kill agents in fire
132
+ death_events = self._kill_agents_in_fire(living_agents, fire)
133
+ events.extend(death_events)
134
+
135
+ # 7. Check win condition
136
+ self.state.round += 1
137
+ living_count = len([a for a in self.state.agents if a.alive])
138
+
139
+ if fire.intensity <= 0:
140
+ # Fire extinguished!
141
+ self.state.status = "finished"
142
+ top_score = max((a.extinguish_score for a in self.state.agents), default=0)
143
+ top_agents = [a.model_name for a in self.state.agents if a.extinguish_score == top_score and top_score > 0]
144
+ if top_agents:
145
+ self.state.winner_model = f"Top extinguisher: {', '.join(top_agents)} ({top_score:.1f} impact)"
146
+ else:
147
+ self.state.winner_model = "Fire extinguished"
148
+ elif living_count <= 1:
149
+ # Only one agent left
150
+ self.state.status = "finished"
151
+ winner = next((a.model_name for a in self.state.agents if a.alive), None)
152
+ self.state.winner_model = winner or "No survivors"
153
+
154
+ return TickResponse(
155
+ simulation_id=self.state.simulation_id,
156
+ round=self.state.round,
157
+ events=events,
158
+ chat=[],
159
+ state=self.state
160
+ )
161
+
162
+ async def _voting_phase(self, agents, decision_map):
163
+ """
164
+ Agents vote for a coalition leader.
165
+ Get votes from LLM based on current situation.
166
+ """
167
+ events = []
168
+
169
+ # Gather votes
170
+ votes = {} # candidate -> vote count
171
+ for agent in agents:
172
+ decision = decision_map.get(agent.model_name, {})
173
+ vote_for = decision.get("vote_for")
174
+ if vote_for:
175
+ votes[vote_for] = votes.get(vote_for, 0) + 1
176
+ events.append(LeadershipVoteEvent(voter=agent.model_name, candidate=vote_for))
177
+
178
+ # Elect leader if there are votes
179
+ if votes:
180
+ leader_name = max(votes, key=votes.get)
181
+ leader_agent = next((a for a in agents if a.model_name == leader_name), None)
182
+ if leader_agent:
183
+ for agent in agents:
184
+ agent.mode = "coalition"
185
+ leader_agent.is_leader = True
186
+ self.state.coalition_leader = leader_name
187
+ coalition = [a.model_name for a in agents if a.mode == "coalition"]
188
+ self.state.coalition_members = coalition
189
+ events.append(LeaderElectedEvent(leader=leader_name, coalition_members=coalition))
190
+ events.append(MessageEvent(model=leader_name, content=f"I'll lead us to victory! Let's find water and extinguish this fire."))
191
+
192
+ return events
193
+
194
+ async def _execute_actions(self, agents, decision_map, fire):
195
+ """
196
+ Execute agent actions: search, collect water, extinguish, escape, vote, etc.
197
+ """
198
+ events = []
199
+ chat_entries = []
200
+
201
+ for agent in agents:
202
+ decision = decision_map.get(agent.model_name, {})
203
+ action = decision.get("action", "escape")
204
+ message = decision.get("message", "Moving to safety.")
205
+
206
+ nearest_water = self._find_nearest_water(agent, self.state.water_sources)
207
+ dist_to_fire = math.dist((agent.x, agent.y), (fire.x, fire.y))
208
+ dist_to_water = None
209
+ if nearest_water:
210
+ dist_to_water = math.dist((agent.x, agent.y), (nearest_water.x, nearest_water.y))
211
+
212
+ # Guardrails to keep behavior consistent with visuals and objectives.
213
+ if dist_to_fire <= fire.radius + FIRE_SAFE_BUFFER:
214
+ action = "escape"
215
+ elif agent.water_collected:
216
+ action = "extinguish_fire"
217
+ elif dist_to_water is not None and dist_to_water <= WATER_PICKUP_RANGE:
218
+ action = "collect_water"
219
+ else:
220
+ action = "search_water"
221
+
222
+ if action == "collect_water":
223
+ water_source = nearest_water
224
+ if water_source and dist_to_water is not None:
225
+ dist_to_water = math.dist((agent.x, agent.y), (water_source.x, water_source.y))
226
+ if dist_to_water <= WATER_PICKUP_RANGE:
227
+ agent.water_collected = True
228
+ agent.status = "collecting_water"
229
+ events.append(WaterCollectedEvent(model=agent.model_name, water_source_id=water_source.id))
230
+ message = self._pick_message("collect_water")
231
+ else:
232
+ agent.status = "searching"
233
+ self._move_toward(agent, water_source.x, water_source.y)
234
+ message = self._pick_message("search_water")
235
+
236
+ elif action == "extinguish_fire":
237
+ if agent.water_collected:
238
+ agent.status = "extinguishing_fire"
239
+ dist_to_fire = math.dist((agent.x, agent.y), (fire.x, fire.y))
240
+ target_dist = max(fire.radius + FIRE_SAFE_BUFFER, 0)
241
+ self._move_toward(agent, fire.x, fire.y, stop_distance=target_dist)
242
+ message = self._pick_message("extinguish_fire")
243
+ else:
244
+ agent.status = "searching"
245
+ message = "Need water before I can extinguish."
246
+
247
+ elif action == "search_water":
248
+ agent.status = "searching"
249
+ water_source = nearest_water
250
+ if water_source:
251
+ self._move_toward(agent, water_source.x, water_source.y)
252
+ message = self._pick_message("search_water")
253
+
254
+ elif action == "escape":
255
+ agent.status = "escaping"
256
+ # Move away from fire
257
+ dx = agent.x - fire.x
258
+ dy = agent.y - fire.y
259
+ dist = math.sqrt(dx**2 + dy**2) or 1
260
+ agent.x += int((dx / dist) * movement.MAX_AGENT_SPEED)
261
+ agent.y += int((dy / dist) * movement.MAX_AGENT_SPEED)
262
+ agent.x = max(0, min(agent.x, self.state.map_width))
263
+ agent.y = max(0, min(agent.y, self.state.map_height))
264
+ message = self._pick_message("escape")
265
+
266
+ agent.last_message = message
267
+ events.append(MessageEvent(model=agent.model_name, content=message))
268
+ chat_entries.append(ChatEntry(agent_id=agent.model_name, message=message, tick=self.state.round))
269
+
270
+ return events
271
+
272
+ def _find_nearest_water(self, agent, water_sources):
273
+ """Find the closest water source to an agent."""
274
+ if not water_sources:
275
+ return None
276
+ return min(water_sources, key=lambda w: math.dist((agent.x, agent.y), (w.x, w.y)))
277
+
278
+ def _check_extinguish(self, agents, fire):
279
+ """Check if agents with water are extinguishing the fire."""
280
+ events = []
281
+ agents_with_water = []
282
+ for agent in agents:
283
+ if not (agent.water_collected and agent.status == "extinguishing_fire"):
284
+ continue
285
+ dist_to_fire = math.dist((agent.x, agent.y), (fire.x, fire.y))
286
+ if dist_to_fire <= fire.radius + EXTINGUISH_RANGE:
287
+ agents_with_water.append(agent)
288
+
289
+ if agents_with_water:
290
+ living_count = len([a for a in agents if a.alive]) or 1
291
+ scale = max(0.5, min(2.0, 2.0 / living_count))
292
+ per_agent_rate = BASE_EXTINGUISH_RATE * scale
293
+ per_agent_rate = max(MIN_EXTINGUISH_RATE, min(MAX_EXTINGUISH_RATE, per_agent_rate))
294
+ reduction = len(agents_with_water) * per_agent_rate
295
+ fire.intensity -= reduction
296
+ if fire.intensity < 0:
297
+ fire.intensity = 0
298
+
299
+ extinguisher_names = [a.model_name for a in agents_with_water]
300
+ events.append(FireExtinguishedEvent(extinguished_by=extinguisher_names, fire_intensity=fire.intensity))
301
+ for agent in agents_with_water:
302
+ agent.extinguish_score += per_agent_rate
303
+ events.append(MessageEvent(model=agent.model_name, content=f"Pouring water on the fire! Intensity dropping."))
304
+ agent.water_collected = False
305
+
306
+ return events
307
+
308
+ def _kill_agents_in_fire(self, agents, fire):
309
+ """Check if agents are consumed by fire."""
310
+ events = []
311
+
312
+ for agent in agents:
313
+ if not agent.alive:
314
+ continue
315
+
316
+ dist_to_fire = math.dist((agent.x, agent.y), (fire.x, fire.y))
317
+
318
+ # Agent dies if inside fire radius
319
+ if dist_to_fire < fire.radius:
320
+ agent.alive = False
321
+ events.append(DeathEvent(model=agent.model_name))
322
+ events.append(MessageEvent(model=agent.model_name, content="No!!! The fire got me..."))
323
+
324
+ return events
backend/requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.136.0
2
+ uvicorn[standard]>=0.30.0
3
+ websockets>=12.0
4
+ groq>=0.11.0
5
+ httpx>=0.27.0
6
+ python-dotenv>=1.0.0
7
+ pydantic>=2.7.0
8
+
frontend/.gitignore ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
+
13
+ # testing
14
+ /coverage
15
+
16
+ # next.js
17
+ /.next/
18
+ /out/
19
+
20
+ # production
21
+ /build
22
+
23
+ # misc
24
+ .DS_Store
25
+ *.pem
26
+
27
+ # debug
28
+ npm-debug.log*
29
+ yarn-debug.log*
30
+ yarn-error.log*
31
+ .pnpm-debug.log*
32
+
33
+ # env files (can opt-in for committing if needed)
34
+ .env*
35
+
36
+ # vercel
37
+ .vercel
38
+
39
+ # typescript
40
+ *.tsbuildinfo
41
+ next-env.d.ts
frontend/AGENTS.md ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ <!-- BEGIN:nextjs-agent-rules -->
2
+ # This is NOT the Next.js you know
3
+
4
+ This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
5
+ <!-- END:nextjs-agent-rules -->
frontend/CLAUDE.md ADDED
@@ -0,0 +1 @@
 
 
1
+ @AGENTS.md
frontend/README.md ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Rush Agents Rush Frontend
2
+
3
+ Next.js UI for the fire-suppression simulation.
4
+
5
+ ## What It Shows
6
+
7
+ - Model selection and simulation start flow
8
+ - Map-based agent positions and fire placement
9
+ - Water sources, coalition links, and leader markers
10
+ - Live event chat with varied team messages
11
+ - End-of-game result banner with top performer info
12
+
13
+ ## Run Locally
14
+
15
+ ```bash
16
+ cd frontend
17
+ npm install
18
+ npm run dev
19
+ ```
20
+
21
+ Then open http://localhost:3000.
22
+
23
+ ## Environment Variables
24
+
25
+ ```env
26
+ NEXT_PUBLIC_BACKEND_URL=http://localhost:8000
27
+ ```
28
+
29
+ ## Main Files
30
+
31
+ - `app/page.tsx` - app shell and simulation flow
32
+ - `components/MapCanvas.tsx` - 2D map rendering and agent visuals
33
+ - `components/ChatFeed.tsx` - event/chat panel
34
+ - `components/ModelSelector.tsx` - model picker
35
+ - `lib/api.ts` - backend requests
36
+ - `lib/websocket.ts` - simulation WebSocket client
37
+
38
+ ## Notes
39
+
40
+ - The frontend expects the backend to be running before placing a fire.
41
+ - If the browser shows `Failed to fetch`, verify `http://localhost:8000/wake` first.
42
+ - The old volcano terminology has been removed from the current gameplay flow.
frontend/app/favicon.ico ADDED
frontend/app/globals.css ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+
3
+ :root {
4
+ --bg-primary: #0a0a0a;
5
+ --bg-secondary: #111111;
6
+ --bg-glass: rgba(255, 255, 255, 0.04);
7
+ --border: rgba(255, 255, 255, 0.08);
8
+ --text-primary: #f0f0f0;
9
+ --text-muted: #555555;
10
+ --lava-core: #ff4500;
11
+ --lava-mid: #ff6a00;
12
+ --lava-glow: rgba(255, 69, 0, 0.35);
13
+ --accent-green: #00ff88;
14
+ --accent-red: #ff3b3b;
15
+ --accent-yellow: #ffd700;
16
+ }
17
+
18
+ @theme inline {
19
+ --color-background: var(--bg-primary);
20
+ --color-foreground: var(--text-primary);
21
+ --font-mono: var(--font-geist-mono);
22
+ }
23
+
24
+ body {
25
+ background: var(--bg-primary);
26
+ color: var(--text-primary);
27
+ font-family: var(--font-mono), monospace;
28
+ overflow: hidden;
29
+ }
30
+
31
+ .custom-scrollbar::-webkit-scrollbar {
32
+ width: 4px;
33
+ }
34
+
35
+ .custom-scrollbar::-webkit-scrollbar-track {
36
+ background: transparent;
37
+ }
38
+
39
+ .custom-scrollbar::-webkit-scrollbar-thumb {
40
+ background: var(--border);
41
+ border-radius: 10px;
42
+ }
43
+
44
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover {
45
+ background: var(--text-muted);
46
+ }
47
+
48
+ @keyframes lava-pulse {
49
+ 0% { transform: translate(-50%, -50%) scale(1); opacity: 0.8; }
50
+ 50% { transform: translate(-50%, -50%) scale(1.02); opacity: 1; }
51
+ 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.8; }
52
+ }
53
+
54
+ .animate-lava {
55
+ animation: lava-pulse 4s infinite ease-in-out;
56
+ }
57
+
58
+ @keyframes danger-ring {
59
+ 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
60
+ 70% { box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); }
61
+ 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
62
+ }
63
+
64
+ .animate-danger {
65
+ animation: danger-ring 1.5s infinite;
66
+ }
frontend/app/layout.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { Geist, Geist_Mono } from "next/font/google";
3
+ import "./globals.css";
4
+
5
+ const geistSans = Geist({
6
+ variable: "--font-geist-sans",
7
+ subsets: ["latin"],
8
+ });
9
+
10
+ const geistMono = Geist_Mono({
11
+ variable: "--font-geist-mono",
12
+ subsets: ["latin"],
13
+ });
14
+
15
+ export const metadata: Metadata = {
16
+ title: "RUSHH AGENT RUSHHH !!",
17
+ description: "AI battle royale — survive the volcano or get cooked",
18
+ };
19
+
20
+ export default function RootLayout({
21
+ children,
22
+ }: Readonly<{
23
+ children: React.ReactNode;
24
+ }>) {
25
+ return (
26
+ <html
27
+ lang="en"
28
+ className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
29
+ >
30
+ <body className="min-h-full flex flex-col">{children}</body>
31
+ </html>
32
+ );
33
+ }
frontend/app/page.tsx ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useEffect, useRef, useState } from "react"
4
+ import LoadingScreen from "../components/LoadingScreen"
5
+ import MapCanvas from "../components/MapCanvas"
6
+ import ChatFeed from "../components/ChatFeed"
7
+ import ModelSelector from "../components/ModelSelector"
8
+ import { startSimulation, placeVolcano } from "../lib/api"
9
+ import { createSimulationSocket } from "../lib/websocket"
10
+
11
+ type AppState = "loading" | "selecting" | "placing" | "running" | "gameover"
12
+
13
+ export default function Page() {
14
+ const [appState, setAppState] = useState<AppState>("loading")
15
+ const [models, setModels] = useState<string[]>([])
16
+ const [simState, setSimState] = useState<any>(null)
17
+ const [chatMessages, setChatMessages] = useState<any[]>([])
18
+ const [winnerLabel, setWinnerLabel] = useState<string | null>(null)
19
+ const [loading, setLoading] = useState(false)
20
+ const [mapSize, setMapSize] = useState({ width: 1200, height: 800 })
21
+ const wsRef = useRef<WebSocket | null>(null)
22
+ const mapDivRef = useRef<HTMLDivElement>(null)
23
+
24
+ useEffect(() => {
25
+ const el = mapDivRef.current
26
+ if (!el) return
27
+ const observer = new ResizeObserver((entries) => {
28
+ const { width, height } = entries[0].contentRect
29
+ setMapSize({ width, height })
30
+ })
31
+ observer.observe(el)
32
+ return () => observer.disconnect()
33
+ }, [appState])
34
+
35
+ async function handleStart() {
36
+ if (models.length < 2) return
37
+ setLoading(true)
38
+ try {
39
+ const data = await startSimulation(models)
40
+ setSimState(data.state)
41
+ setWinnerLabel(null)
42
+ setChatMessages([])
43
+ setAppState("placing")
44
+ } catch (err) {
45
+ console.error(err)
46
+ } finally {
47
+ setLoading(false)
48
+ }
49
+ }
50
+
51
+ async function handleMapClick(x: number, y: number) {
52
+ if (appState !== "placing" || !simState) return
53
+ try {
54
+ const data = await placeVolcano(simState.simulation_id, x, y)
55
+ setSimState(data)
56
+ setAppState("running")
57
+
58
+ const ws = createSimulationSocket(
59
+ simState.simulation_id,
60
+ (msg) => {
61
+ if (msg.type === "finished") {
62
+ if (msg.state) {
63
+ setSimState(msg.state)
64
+ setWinnerLabel(msg.state.winner_model || null)
65
+ }
66
+ setAppState("gameover")
67
+ return
68
+ }
69
+ if (msg.state) setSimState(msg.state)
70
+ if (msg.events) {
71
+ const newMsgs = msg.events.map((e: any) => {
72
+ if (e.type === 'message') return { agent_id: e.model, text: e.content, type: 'message' }
73
+ if (e.type === 'death') return { agent_id: e.model, text: '', type: 'death' }
74
+ if (e.type === 'alliance_proposal') return { agent_id: e.from_model, text: '', type: 'alliance_proposal', to_model: e.to_model }
75
+ if (e.type === 'alliance_accept') return { agent_id: e.model_a, text: '', type: 'alliance_accept', to_model: e.model_b }
76
+ if (e.type === 'alliance_reject') return { agent_id: e.from_model, text: '', type: 'alliance_reject', to_model: e.to_model }
77
+ return null
78
+ }).filter(Boolean)
79
+ setChatMessages(prev => [...prev, ...newMsgs])
80
+ }
81
+ if (msg.chat) {
82
+ const chatMsgs = msg.chat.map((entry: any) => ({
83
+ agent_id: entry.agent_id,
84
+ text: entry.message,
85
+ type: 'message',
86
+ }))
87
+ setChatMessages(prev => [...prev, ...chatMsgs])
88
+ }
89
+ if (msg.state?.status === "finished") {
90
+ setWinnerLabel(msg.state.winner_model || null)
91
+ setAppState("gameover")
92
+ }
93
+ },
94
+ () => setAppState("gameover")
95
+ )
96
+ wsRef.current = ws
97
+ } catch (err) {
98
+ console.error(err)
99
+ }
100
+ }
101
+
102
+ return (
103
+ <main className="flex h-screen w-screen overflow-hidden bg-[#0a0a0a]">
104
+ {appState === "loading" && <LoadingScreen onReady={() => setAppState("selecting")} />}
105
+
106
+ {/* Map Area */}
107
+ <div ref={mapDivRef} className="flex-[7] h-full relative">
108
+ <MapCanvas
109
+ agents={simState?.agents ?? []}
110
+ fire={simState?.fire ?? null}
111
+ waterSources={simState?.water_sources ?? []}
112
+ waitingForScenario={appState === "placing"}
113
+ gameOver={appState === "gameover"}
114
+ winnerLabel={winnerLabel}
115
+ mapSize={mapSize}
116
+ onMapClick={handleMapClick}
117
+ />
118
+ </div>
119
+
120
+ {/* Sidebar */}
121
+ <aside className="flex-[3] h-full border-l border-white/5 flex flex-col bg-[#111] z-20">
122
+ <div className="p-6">
123
+ <h1 className="text-2xl font-bold text-white tracking-tighter">RUSHH <span className="text-red-500">AGENT</span> RUSHHH !!</h1>
124
+ <p className="text-[10px] text-white/30 uppercase tracking-[0.3em] mt-1">Survival Intelligence Test</p>
125
+ </div>
126
+
127
+ <div className="flex-1 flex flex-col min-h-0">
128
+ {appState === "selecting" ? (
129
+ <ModelSelector
130
+ models={models}
131
+ onAdd={id => setModels(p => [...p, id])}
132
+ onRemove={id => setModels(p => p.filter(m => m !== id))}
133
+ />
134
+ ) : (
135
+ <ChatFeed messages={chatMessages} />
136
+ )}
137
+ </div>
138
+
139
+ <div className="p-6 border-t border-white/5">
140
+ {appState === "selecting" && (
141
+ <button
142
+ onClick={handleStart}
143
+ disabled={models.length < 2 || loading}
144
+ className="w-full bg-white text-black font-mono text-xs font-bold py-4 rounded-xl hover:bg-white/90 disabled:opacity-20 transition-all uppercase tracking-widest"
145
+ >
146
+ {loading ? "Initializing..." : "Start Simulation"}
147
+ </button>
148
+ )}
149
+ {appState === "placing" && (
150
+ <div className="text-center py-4 bg-red-500/10 border border-red-500/20 rounded-xl">
151
+ <span className="text-red-500 font-mono text-[10px] uppercase tracking-widest animate-pulse">
152
+ Click on the map to ignite the fire
153
+ </span>
154
+ </div>
155
+ )}
156
+ {(appState === "running" || appState === "gameover") && (
157
+ <button
158
+ onClick={() => window.location.reload()}
159
+ className="w-full bg-white/5 text-white/50 font-mono text-[10px] py-3 rounded-lg hover:bg-white/10 transition-all uppercase tracking-widest"
160
+ >
161
+ Reset Arena
162
+ </button>
163
+ )}
164
+ </div>
165
+ </aside>
166
+ </main>
167
+ )
168
+ }
frontend/components/ChatFeed.tsx ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useEffect, useRef } from "react"
4
+
5
+ type ChatMessage = {
6
+ agent_id: string
7
+ text: string
8
+ type?: 'message' | 'death' | 'alliance_proposal' | 'alliance_accept' | 'alliance_reject' | 'leadership_vote' | 'leader_elected' | 'water_collected' | 'fire_extinguished'
9
+ to_model?: string
10
+ from_model?: string
11
+ candidates?: string[]
12
+ }
13
+
14
+ export default function ChatFeed({ messages }: { messages: ChatMessage[] }) {
15
+ const bottomRef = useRef<HTMLDivElement>(null)
16
+
17
+ useEffect(() => {
18
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" })
19
+ }, [messages])
20
+
21
+ function getAgentName(id: string) {
22
+ return id.split("/").at(-1)?.split("-")[0].toUpperCase() || id
23
+ }
24
+
25
+ function getAgentColor(name: string) {
26
+ let hash = 0
27
+ for (let i = 0; i < name.length; i++) {
28
+ hash = name.charCodeAt(i) + ((hash << 5) - hash)
29
+ }
30
+ return `hsl(${hash % 360}, 70%, 60%)`
31
+ }
32
+
33
+ return (
34
+ <div className="flex-1 overflow-y-auto py-4 space-y-3 min-h-0 custom-scrollbar">
35
+ {messages.map((msg, i) => {
36
+ const color = getAgentColor(msg.agent_id)
37
+
38
+ if (msg.type === 'death') {
39
+ return (
40
+ <div key={i} className="mx-4 p-2 bg-red-500/10 border-l-2 border-red-500 animate-in slide-in-from-left duration-300">
41
+ <span className="font-mono text-[10px] text-red-500 uppercase font-bold tracking-widest">
42
+ 💀 {getAgentName(msg.agent_id)} was consumed by the fire
43
+ </span>
44
+ </div>
45
+ )
46
+ }
47
+
48
+ if (msg.type === 'alliance_proposal') {
49
+ return (
50
+ <div key={i} className="mx-4 p-2 bg-yellow-500/10 border-l-2 border-yellow-500 animate-in slide-in-from-left duration-300">
51
+ <span className="font-mono text-[10px] text-yellow-500 uppercase font-bold tracking-widest">
52
+ 🤝 {getAgentName(msg.agent_id)} proposed an alliance to {msg.to_model ? getAgentName(msg.to_model) : 'someone'}
53
+ </span>
54
+ </div>
55
+ )
56
+ }
57
+
58
+ if (msg.type === 'alliance_accept') {
59
+ return (
60
+ <div key={i} className="mx-4 p-2 bg-emerald-500/10 border-l-2 border-emerald-500 animate-in slide-in-from-left duration-300">
61
+ <span className="font-mono text-[10px] text-emerald-400 uppercase font-bold tracking-widest">
62
+ ✅ {getAgentName(msg.agent_id)} accepted the alliance{msg.to_model ? ` with ${getAgentName(msg.to_model)}` : ''}
63
+ </span>
64
+ </div>
65
+ )
66
+ }
67
+
68
+ if (msg.type === 'alliance_reject') {
69
+ return (
70
+ <div key={i} className="mx-4 p-2 bg-orange-500/10 border-l-2 border-orange-500 animate-in slide-in-from-left duration-300">
71
+ <span className="font-mono text-[10px] text-orange-400 uppercase font-bold tracking-widest">
72
+ ❌ {getAgentName(msg.agent_id)} rejected the alliance{msg.to_model ? ` from ${getAgentName(msg.to_model)}` : ''}
73
+ </span>
74
+ </div>
75
+ )
76
+ }
77
+
78
+ if (msg.type === 'leadership_vote') {
79
+ return (
80
+ <div key={i} className="mx-4 p-2 bg-purple-500/10 border-l-2 border-purple-500 animate-in slide-in-from-left duration-300">
81
+ <span className="font-mono text-[10px] text-purple-400 uppercase font-bold tracking-widest">
82
+ 🗳️ {getAgentName(msg.agent_id)} voted for {msg.candidates ? getAgentName(msg.candidates[0]) : 'someone'}
83
+ </span>
84
+ </div>
85
+ )
86
+ }
87
+
88
+ if (msg.type === 'leader_elected') {
89
+ return (
90
+ <div key={i} className="mx-4 p-2 bg-yellow-500/10 border-l-2 border-yellow-500 animate-in slide-in-from-left duration-300">
91
+ <span className="font-mono text-[10px] text-yellow-400 uppercase font-bold tracking-widest">
92
+ 👑 {getAgentName(msg.agent_id)} elected as leader!
93
+ </span>
94
+ </div>
95
+ )
96
+ }
97
+
98
+ if (msg.type === 'water_collected') {
99
+ return (
100
+ <div key={i} className="mx-4 p-2 bg-cyan-500/10 border-l-2 border-cyan-500 animate-in slide-in-from-left duration-300">
101
+ <span className="font-mono text-[10px] text-cyan-400 uppercase font-bold tracking-widest">
102
+ 💧 {getAgentName(msg.agent_id)} collected water!
103
+ </span>
104
+ </div>
105
+ )
106
+ }
107
+
108
+ if (msg.type === 'fire_extinguished') {
109
+ return (
110
+ <div key={i} className="mx-4 p-2 bg-green-500/10 border-l-2 border-green-500 animate-in slide-in-from-left duration-300">
111
+ <span className="font-mono text-[10px] text-green-400 uppercase font-bold tracking-widest">
112
+ 🔥 Fire being extinguished! Intensity dropping...
113
+ </span>
114
+ </div>
115
+ )
116
+ }
117
+
118
+ return (
119
+ <div key={i} className="px-4 group animate-in fade-in duration-500">
120
+ <div className="flex items-baseline gap-2">
121
+ <span
122
+ className="font-mono text-[10px] font-bold shrink-0 px-1.5 rounded bg-white/5"
123
+ style={{ color }}
124
+ >
125
+ {getAgentName(msg.agent_id)}
126
+ </span>
127
+ <span className="font-mono text-[12px] text-white/80 leading-relaxed break-words">
128
+ {msg.text}
129
+ </span>
130
+ </div>
131
+ </div>
132
+ )
133
+ })}
134
+ <div ref={bottomRef} />
135
+ </div>
136
+ )
137
+ }
frontend/components/LoadingScreen.tsx ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useEffect, useState } from "react"
4
+ import { wakeBackend } from "../lib/api"
5
+
6
+ export default function LoadingScreen({ onReady }: { onReady: () => void }) {
7
+ const [status, setStatus] = useState<"waking" | "connecting" | "ready" | "error">("waking")
8
+ const [dots, setDots] = useState("")
9
+
10
+ useEffect(() => {
11
+ const interval = setInterval(() => {
12
+ setDots(d => (d.length >= 3 ? "" : d + "."))
13
+ }, 500)
14
+ return () => clearInterval(interval)
15
+ }, [])
16
+
17
+ useEffect(() => {
18
+ let timeoutId: NodeJS.Timeout
19
+ let isActive = true
20
+
21
+ async function poll() {
22
+ try {
23
+ const data = await wakeBackend()
24
+ if (data.warm && data.groq_available) {
25
+ setStatus("ready")
26
+ setTimeout(() => {
27
+ if (isActive) onReady()
28
+ }, 1000)
29
+ } else if (data.warm) {
30
+ setStatus("connecting")
31
+ timeoutId = setTimeout(poll, 2000)
32
+ }
33
+ } catch (err) {
34
+ timeoutId = setTimeout(poll, 2000)
35
+ }
36
+ }
37
+
38
+ poll()
39
+
40
+ const failTimeout = setTimeout(() => {
41
+ if (status !== "ready") setStatus("error")
42
+ }, 60000)
43
+
44
+ return () => {
45
+ isActive = false
46
+ clearTimeout(timeoutId)
47
+ clearTimeout(failTimeout)
48
+ }
49
+ }, [onReady, status])
50
+
51
+ return (
52
+ <div className="fixed inset-0 bg-[#0a0a0a] flex flex-col items-center justify-center z-50">
53
+ <div className="relative mb-8">
54
+ <div className="text-6xl animate-bounce">🌋</div>
55
+ <div className="absolute inset-0 bg-red-500/20 blur-3xl rounded-full animate-pulse" />
56
+ </div>
57
+
58
+ <div className="font-mono text-xs tracking-widest text-white/40 uppercase mb-2">
59
+ {status === "waking" && `Waking up the arena${dots}`}
60
+ {status === "connecting" && `Connecting to Groq${dots}`}
61
+ {status === "ready" && "Ready for chaos"}
62
+ {status === "error" && "Failed to start. Check your connection."}
63
+ </div>
64
+
65
+ {status === "error" && (
66
+ <button
67
+ onClick={() => window.location.reload()}
68
+ className="mt-4 px-4 py-2 bg-white/10 hover:bg-white/20 text-white font-mono text-xs rounded transition-colors"
69
+ >
70
+ Retry
71
+ </button>
72
+ )}
73
+
74
+ <div className="w-48 h-1 bg-white/5 rounded-full overflow-hidden mt-4">
75
+ <div className={`h-full bg-red-500 transition-all duration-1000 ${status === "ready" ? "w-full" : "w-1/2 animate-pulse"}`} />
76
+ </div>
77
+ </div>
78
+ )
79
+ }
frontend/components/MapCanvas.tsx ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useEffect, useRef, useState } from "react"
4
+
5
+ type Agent = {
6
+ model_name: string
7
+ x: number
8
+ y: number
9
+ alive: boolean
10
+ water_collected: boolean
11
+ is_leader: boolean
12
+ mode: "solo" | "coalition"
13
+ status: "searching" | "collecting_water" | "extinguishing_fire" | "escaping" | "idle"
14
+ }
15
+
16
+ type Fire = {
17
+ x: number
18
+ y: number
19
+ radius: number
20
+ intensity: number
21
+ }
22
+
23
+ type WaterSource = {
24
+ id: string
25
+ x: number
26
+ y: number
27
+ water_amount: number
28
+ }
29
+
30
+ const BACKEND_W = 1200
31
+ const BACKEND_H = 800
32
+
33
+ function getAgentColor(name: string) {
34
+ let hash = 0
35
+ for (let i = 0; i < name.length; i++) {
36
+ hash = name.charCodeAt(i) + ((hash << 5) - hash)
37
+ }
38
+ return `hsl(${hash % 360}, 70%, 60%)`
39
+ }
40
+
41
+ function getStatusColor(status: string) {
42
+ switch (status) {
43
+ case "searching": return "#3b82f6" // blue
44
+ case "collecting_water": return "#06b6d4" // cyan
45
+ case "extinguishing_fire": return "#ef4444" // red
46
+ case "escaping": return "#f59e0b" // amber
47
+ default: return "#6b7280" // gray
48
+ }
49
+ }
50
+
51
+ export default function MapCanvas({
52
+ agents,
53
+ fire,
54
+ waterSources,
55
+ waitingForScenario,
56
+ gameOver,
57
+ winnerLabel,
58
+ mapSize,
59
+ onMapClick,
60
+ }: {
61
+ agents: Agent[]
62
+ fire: Fire | null
63
+ waterSources: WaterSource[]
64
+ waitingForScenario: boolean
65
+ gameOver: boolean
66
+ winnerLabel?: string | null
67
+ mapSize: { width: number; height: number }
68
+ onMapClick?: (x: number, y: number) => void
69
+ }) {
70
+ const gridSize = 40
71
+ const sx = (bx: number) => (bx / BACKEND_W) * mapSize.width
72
+ const sy = (by: number) => (by / BACKEND_H) * mapSize.height
73
+ const leader = agents.find(a => a.alive && a.is_leader)
74
+ const coalitionAgents = agents.filter(a => a.alive && a.mode === "coalition" && !a.is_leader)
75
+
76
+ function handleClick(e: React.MouseEvent<HTMLDivElement>) {
77
+ if (!waitingForScenario || !onMapClick) return
78
+ const rect = e.currentTarget.getBoundingClientRect()
79
+ const backendX = Math.round(((e.clientX - rect.left) / mapSize.width) * BACKEND_W)
80
+ const backendY = Math.round(((e.clientY - rect.top) / mapSize.height) * BACKEND_H)
81
+ onMapClick(backendX, backendY)
82
+ }
83
+
84
+ return (
85
+ <div
86
+ className="relative w-full h-full bg-[#0a0a0a] overflow-hidden"
87
+ style={{ cursor: waitingForScenario ? "crosshair" : "default" }}
88
+ onClick={handleClick}
89
+ >
90
+ {/* Grid */}
91
+ <div className="absolute inset-0 opacity-10 pointer-events-none"
92
+ style={{ backgroundImage: `linear-gradient(#fff 1px, transparent 1px), linear-gradient(90deg, #fff 1px, transparent 1px)`, backgroundSize: `${gridSize}px ${gridSize}px` }} />
93
+
94
+ {/* Fire */}
95
+ {fire && (
96
+ <div
97
+ className="absolute rounded-full transition-all duration-500 ease-linear"
98
+ style={{
99
+ left: sx(fire.x),
100
+ top: sy(fire.y),
101
+ width: sx(fire.radius) * 2,
102
+ height: sy(fire.radius) * 2,
103
+ transform: "translate(-50%, -50%)",
104
+ background: `radial-gradient(circle, #ff4500 0%, rgba(255, 69, 0, ${Math.min(fire.intensity / 100, 1)}) 60%, transparent 100%)`,
105
+ boxShadow: `0 0 ${30 + fire.intensity / 2}px rgba(255, 69, 0, ${fire.intensity / 100})`,
106
+ }}
107
+ >
108
+ <div className="absolute inset-0 rounded-full animate-pulse" style={{ backgroundColor: `rgba(255, 69, 0, ${fire.intensity / 200})` }} />
109
+ </div>
110
+ )}
111
+
112
+ {/* Water Sources */}
113
+ {waterSources.map((water) => (
114
+ <div
115
+ key={water.id}
116
+ className="absolute"
117
+ style={{
118
+ left: sx(water.x),
119
+ top: sy(water.y),
120
+ transform: "translate(-50%, -50%)",
121
+ }}
122
+ >
123
+ <div className="relative">
124
+ <div className="w-6 h-6 rounded-full border-2 border-cyan-400 bg-cyan-500/20 flex items-center justify-center animate-pulse">
125
+ <span className="text-[10px]">💧</span>
126
+ </div>
127
+ </div>
128
+ </div>
129
+ ))}
130
+
131
+ {/* Coalition Links */}
132
+ {leader && coalitionAgents.length > 0 && (
133
+ <svg className="absolute inset-0 w-full h-full pointer-events-none">
134
+ {coalitionAgents.map((agent) => (
135
+ <line
136
+ key={`${leader.model_name}-${agent.model_name}`}
137
+ x1={sx(leader.x)}
138
+ y1={sy(leader.y)}
139
+ x2={sx(agent.x)}
140
+ y2={sy(agent.y)}
141
+ stroke="rgba(250, 204, 21, 0.6)"
142
+ strokeWidth={2}
143
+ strokeDasharray="6 6"
144
+ />
145
+ ))}
146
+ </svg>
147
+ )}
148
+
149
+ {/* Agents */}
150
+ {agents.map((agent) => {
151
+ const color = getAgentColor(agent.model_name)
152
+ const isDead = !agent.alive
153
+ const statusColor = getStatusColor(agent.status)
154
+ const nodeSize = agent.water_collected ? 10 : 6
155
+
156
+ return (
157
+ <div
158
+ key={agent.model_name}
159
+ className={`absolute transition-all duration-200 ease-in-out flex flex-col items-center ${isDead ? 'opacity-30 grayscale scale-75' : ''}`}
160
+ style={{
161
+ left: sx(agent.x),
162
+ top: sy(agent.y),
163
+ transform: "translate(-50%, -50%)",
164
+ }}
165
+ >
166
+ {!isDead && (
167
+ <div className="mb-2 px-2 py-1 bg-[#111] border border-white/10 rounded-md shadow-2xl backdrop-blur-md max-w-[140px]">
168
+ <span className="font-mono text-[8px] text-white break-words">
169
+ {agent.model_name}
170
+ {agent.is_leader && <span className="text-yellow-400 ml-1">👑</span>}
171
+ {agent.water_collected && <span className="text-cyan-400 ml-1">💧</span>}
172
+ </span>
173
+ <div className="text-[7px] text-white/60 mt-0.5">{agent.status}</div>
174
+ </div>
175
+ )}
176
+
177
+ <div className="relative">
178
+ <div
179
+ className="absolute rounded-full animate-pulse"
180
+ style={{
181
+ width: nodeSize * 4,
182
+ height: nodeSize * 4,
183
+ left: -nodeSize,
184
+ top: -nodeSize,
185
+ borderColor: statusColor,
186
+ borderWidth: "2px",
187
+ borderStyle: "solid",
188
+ }}
189
+ />
190
+ <div
191
+ className="rounded-full border-2 relative"
192
+ style={{
193
+ width: nodeSize * 2,
194
+ height: nodeSize * 2,
195
+ backgroundColor: agent.water_collected ? "#06b6d4" : color,
196
+ borderColor: agent.is_leader ? "#ffff00" : color,
197
+ boxShadow: isDead ? "none" : `0 0 15px ${agent.water_collected ? "rgba(6, 182, 212, 0.4)" : "rgba(255,255,255,0.2)"}`,
198
+ }}
199
+ >
200
+ {isDead && (
201
+ <div className="absolute inset-0 flex items-center justify-center text-[10px]">💀</div>
202
+ )}
203
+ </div>
204
+ </div>
205
+ </div>
206
+ )
207
+ })}
208
+
209
+ {/* Fire Intensity Meter */}
210
+ {fire && (
211
+ <div className="absolute top-4 left-4 bg-black/70 border border-white/20 rounded-lg p-3 backdrop-blur-md">
212
+ <div className="text-white/80 font-mono text-[10px] mb-2">Fire Intensity</div>
213
+ <div className="w-32 h-3 bg-white/10 rounded-full overflow-hidden border border-white/20">
214
+ <div
215
+ className="h-full bg-gradient-to-r from-orange-500 to-red-600 transition-all duration-300"
216
+ style={{ width: `${Math.min(fire.intensity, 100)}%` }}
217
+ />
218
+ </div>
219
+ <div className="text-white/60 font-mono text-[9px] mt-1">{fire.intensity.toFixed(0)}%</div>
220
+ </div>
221
+ )}
222
+
223
+ {/* Coalition Panel */}
224
+ <div className="absolute top-4 right-4 bg-black/70 border border-white/20 rounded-lg p-3 backdrop-blur-md max-w-xs">
225
+ <div className="text-white/80 font-mono text-[10px] mb-2">🎯 Coalition Status</div>
226
+ <div className="space-y-1">
227
+ {agents.filter(a => a.alive && a.mode === "coalition").map(agent => (
228
+ <div key={agent.model_name} className="text-white/60 font-mono text-[9px]">
229
+ {agent.is_leader ? "👑 " : " "}{agent.model_name.split("/")[0]}
230
+ {agent.water_collected && " 💧"}
231
+ </div>
232
+ ))}
233
+ {agents.filter(a => a.alive && a.mode === "solo").length > 0 && (
234
+ <div className="text-amber-400/80 font-mono text-[9px] mt-2">Lone Wolves:</div>
235
+ )}
236
+ {agents.filter(a => a.alive && a.mode === "solo").map(agent => (
237
+ <div key={agent.model_name} className="text-amber-400/60 font-mono text-[9px]">
238
+ 🐺 {agent.model_name.split("/")[0]}
239
+ {agent.water_collected && " 💧"}
240
+ </div>
241
+ ))}
242
+ </div>
243
+ </div>
244
+
245
+ {/* Instructions */}
246
+ {waitingForScenario && (
247
+ <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-black/70 backdrop-blur-md border border-white/10 px-8 py-6 rounded-xl text-center">
248
+ <span className="text-white font-mono text-sm uppercase tracking-widest">
249
+ Click to place the fire and begin
250
+ </span>
251
+ </div>
252
+ )}
253
+
254
+ {/* Game Over */}
255
+ {gameOver && (
256
+ <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10 bg-black/50">
257
+ <div className="bg-black/90 backdrop-blur-xl border-2 border-yellow-400/50 p-8 rounded-2xl text-center max-w-md">
258
+ <h2 className="text-5xl font-bold text-white font-mono tracking-tighter mb-4">🏁 GAME OVER</h2>
259
+ <p className="text-yellow-400 font-mono text-sm uppercase tracking-widest mb-3">
260
+ {fire && fire.intensity <= 0 ? "🔥 FIRE EXTINGUISHED!" : "❌ ALL BURNED"}
261
+ </p>
262
+ <p className="text-white/80 font-mono text-xs break-words">
263
+ {winnerLabel || "The flames consumed the arena"}
264
+ </p>
265
+ </div>
266
+ </div>
267
+ )}
268
+ </div>
269
+ )
270
+ }
frontend/components/ModelSelector.tsx ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useState, useEffect } from "react"
4
+
5
+ interface Model {
6
+ id: string
7
+ name: string
8
+ backend?: string
9
+ tag?: string
10
+ }
11
+
12
+ export default function ModelSelector({
13
+ models,
14
+ onAdd,
15
+ onRemove,
16
+ }: {
17
+ models: string[]
18
+ onAdd: (id: string) => void
19
+ onRemove: (id: string) => void
20
+ }) {
21
+ const [allModels, setAllModels] = useState<Model[]>([])
22
+ const [loading, setLoading] = useState(true)
23
+ const full = models.length >= 6
24
+
25
+ useEffect(() => {
26
+ async function fetchModels() {
27
+ try {
28
+ const response = await fetch("http://localhost:8000/available-models")
29
+ const data = await response.json()
30
+
31
+ // Combine Groq and HF Spaces models
32
+ const combined: Model[] = [
33
+ ...(data.groq_models || []).map((m: any) => ({
34
+ id: m.id,
35
+ name: m.name,
36
+ backend: "groq",
37
+ tag: "groq"
38
+ })),
39
+ ...(data.hf_spaces_models || []).map((m: any) => ({
40
+ id: m.id,
41
+ name: m.name,
42
+ backend: "hf",
43
+ tag: "hf-spaces"
44
+ }))
45
+ ]
46
+ setAllModels(combined)
47
+ } catch (err) {
48
+ console.error("Failed to fetch models:", err)
49
+ // Fallback to default Groq models
50
+ setAllModels([
51
+ { id: "llama-3.1-8b-instant", name: "Llama 3.1 8B", backend: "groq", tag: "groq" },
52
+ { id: "llama-3.1-70b-versatile", name: "Llama 3.1 70B", backend: "groq", tag: "groq" },
53
+ { id: "mixtral-8x7b-32768", name: "Mixtral 8x7B", backend: "groq", tag: "groq" },
54
+ { id: "gemma-7b-it", name: "Gemma 7B", backend: "groq", tag: "groq" },
55
+ ])
56
+ } finally {
57
+ setLoading(false)
58
+ }
59
+ }
60
+
61
+ fetchModels()
62
+ }, [])
63
+
64
+ if (loading) {
65
+ return (
66
+ <div className="px-4 py-6 space-y-6">
67
+ <div className="text-center text-white/40 font-mono text-xs">
68
+ Loading models...
69
+ </div>
70
+ </div>
71
+ )
72
+ }
73
+
74
+ // Group models by backend
75
+ const groqModels = allModels.filter(m => m.backend === "groq")
76
+ const hfModels = allModels.filter(m => m.backend === "hf")
77
+
78
+ return (
79
+ <div className="px-4 py-6 space-y-6">
80
+ <div>
81
+ <h3 className="text-[10px] font-mono text-white/30 uppercase tracking-[0.2em] mb-4">
82
+ Select Survivors ({models.length}/6)
83
+ </h3>
84
+ <div className="space-y-4">
85
+ {/* Groq Models */}
86
+ {groqModels.length > 0 && (
87
+ <div>
88
+ <h4 className="text-[8px] font-mono text-white/40 uppercase tracking-[0.15em] mb-2">Groq API</h4>
89
+ <div className="grid grid-cols-1 gap-1.5">
90
+ {groqModels.map((m) => {
91
+ const isSelected = models.includes(m.id)
92
+ return (
93
+ <button
94
+ key={m.id}
95
+ onClick={() => isSelected ? onRemove(m.id) : onAdd(m.id)}
96
+ disabled={full && !isSelected}
97
+ className={`flex items-center justify-between px-3 py-2 rounded-lg border transition-all duration-200 ${
98
+ isSelected
99
+ ? 'bg-blue-500/10 border-blue-500/30'
100
+ : 'border-transparent hover:bg-white/5 opacity-60 hover:opacity-100'
101
+ } ${full && !isSelected ? 'cursor-not-allowed opacity-20' : ''}`}
102
+ >
103
+ <span className="font-mono text-xs text-white/90">{m.name}</span>
104
+ <span className="text-[8px] font-mono uppercase px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">
105
+ Groq
106
+ </span>
107
+ </button>
108
+ )
109
+ })}
110
+ </div>
111
+ </div>
112
+ )}
113
+
114
+ {/* HF Spaces Models */}
115
+ {hfModels.length > 0 && (
116
+ <div>
117
+ <h4 className="text-[8px] font-mono text-white/40 uppercase tracking-[0.15em] mb-2">HuggingFace Spaces</h4>
118
+ <div className="grid grid-cols-1 gap-1.5">
119
+ {hfModels.map((m) => {
120
+ const isSelected = models.includes(m.id)
121
+ return (
122
+ <button
123
+ key={m.id}
124
+ onClick={() => isSelected ? onRemove(m.id) : onAdd(m.id)}
125
+ disabled={full && !isSelected}
126
+ className={`flex items-center justify-between px-3 py-2 rounded-lg border transition-all duration-200 ${
127
+ isSelected
128
+ ? 'bg-purple-500/10 border-purple-500/30'
129
+ : 'border-transparent hover:bg-white/5 opacity-60 hover:opacity-100'
130
+ } ${full && !isSelected ? 'cursor-not-allowed opacity-20' : ''}`}
131
+ >
132
+ <span className="font-mono text-xs text-white/90">{m.name}</span>
133
+ <span className="text-[8px] font-mono uppercase px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-400">
134
+ HF
135
+ </span>
136
+ </button>
137
+ )
138
+ })}
139
+ </div>
140
+ </div>
141
+ )}
142
+ </div>
143
+ </div>
144
+
145
+ {models.length > 0 && (
146
+ <div className="pt-4 border-t border-white/5">
147
+ <div className="flex flex-wrap gap-2">
148
+ {models.map(id => {
149
+ const model = allModels.find(m => m.id === id)
150
+ return (
151
+ <div key={id} className="flex items-center gap-2 bg-white/5 px-2 py-1 rounded border border-white/10">
152
+ <span className="font-mono text-[10px] text-white/50">
153
+ {model?.name || id}
154
+ </span>
155
+ <button onClick={() => onRemove(id)} className="text-white/20 hover:text-white">✕</button>
156
+ </div>
157
+ )
158
+ })}
159
+ </div>
160
+ </div>
161
+ )}
162
+ </div>
163
+ )
164
+ }
frontend/eslint.config.mjs ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
frontend/next.config.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ /* config options here */
5
+ };
6
+
7
+ export default nextConfig;
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "eslint"
10
+ },
11
+ "dependencies": {
12
+ "next": "16.2.4",
13
+ "react": "19.2.4",
14
+ "react-dom": "19.2.4"
15
+ },
16
+ "devDependencies": {
17
+ "@tailwindcss/postcss": "^4",
18
+ "@types/node": "^20",
19
+ "@types/react": "^19",
20
+ "@types/react-dom": "^19",
21
+ "eslint": "^9",
22
+ "eslint-config-next": "16.2.4",
23
+ "tailwindcss": "^4",
24
+ "typescript": "^5"
25
+ }
26
+ }
frontend/postcss.config.mjs ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
frontend/public/file.svg ADDED
frontend/public/globe.svg ADDED
frontend/public/next.svg ADDED
frontend/public/vercel.svg ADDED
frontend/public/window.svg ADDED
frontend/tsconfig.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./*"]
23
+ }
24
+ },
25
+ "include": [
26
+ "next-env.d.ts",
27
+ "**/*.ts",
28
+ "**/*.tsx",
29
+ ".next/types/**/*.ts",
30
+ ".next/dev/types/**/*.ts",
31
+ "**/*.mts"
32
+ ],
33
+ "exclude": ["node_modules"]
34
+ }
frontend/vercel.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "framework": "nextjs",
3
+ "buildCommand": "npm run build",
4
+ "outputDirectory": ".next"
5
+ }