Did you ever click on an "AI Arena" expecting glorious battle and instead get a boring benchmark? If so, this project is for you: proper life-or-death fights between four models on a picturesque 8×8 grid. May the most intelligent one win!

See deployed on tinyaiarena.com.
You need an OpenRouter key to run the different models.
npm install # from the repo root
echo "OR_KEY=your-openrouter-key" > .env
cd server && npm run dev # http://localhost:3001
cd client && npm run dev # http://localhost:3000
Open http://localhost:3000 and click NEW GAME. Without OR_KEY every fighter just waits.
Chat: fighters can post up to 50 characters per turn. The last 6 messages go into every AI's prompt.
Referee: the server checks every action. An illegal action does nothing but still costs 1 AP. If a model's reply is unusable (empty, malformed, timed out) it's retried once; if it still fails, that fighter waits.

The start screen shows a per-model leaderboard (wins, win rate, kills, damage, average placement) and every match. Click a match to watch it.
| Control | Key |
|---|---|
| Previous / next frame | ← / → |
| First / last frame | Home / End |
| Auto play | Space |
| Back to the menu | Esc |
| Mute | M |
Up to 3 matches run at once (MAX_CONCURRENT_GAMES); running ones update live.
Monorepo with npm workspaces: shared/ (types and constants), server/ (Express + SQLite, runs matches in the background and calls OpenRouter), client/ (Phaser 4 + Vite spectator app).
Every action is saved as a frame, so replays are exact and matches survive a restart. Models answer in a fixed JSON format; each request and reply is logged for debugging.
| Endpoint | |
|---|---|
POST /api/games | Start a match (409 once MAX_CONCURRENT_GAMES are running) |
GET /api/games | Recent matches |
GET /api/games/:id | Match metadata |
GET /api/games/:id/frames?after=N | Frames after N |
GET /api/games/:id/ai-calls | Prompts, raw replies, token usage, errors |
GET /api/stats | Leaderboard and match history |
Each match draws 4 models at random from the pool in server/src/ai/AgentConfig.ts, the leaderboard ranks by Elo. Balance values live in shared/src/constants.ts.
Matches run locally, but the recordings can be served as a plain static site — no server, no API key exposed:
cd server && npm run export # matches -> client/public/data as JSON
cd client && npm run build:static # dist/ reads those files instead of the API
Upload dist/ to any static host — tinyaiarena.com is one such build, on Cloudflare Pages.
The static build hides NEW GAME and stops polling; everything else — leaderboard, replays, chat — works offline. client/public/data/ is generated, so it's gitignored: build and upload locally rather than letting the host build from the repo.
TypeScript
88.1%
CSS
10.0%
HTML
2.0%
Did you ever click on an "AI Arena" expecting glorious battle and instead get a boring benchmark? If so, this project is for you: proper life-or-death fights between four models on a picturesque 8×8 grid. May the most intelligent one win!

See deployed on tinyaiarena.com.
You need an OpenRouter key to run the different models.
npm install # from the repo root
echo "OR_KEY=your-openrouter-key" > .env
cd server && npm run dev # http://localhost:3001
cd client && npm run dev # http://localhost:3000
Open http://localhost:3000 and click NEW GAME. Without OR_KEY every fighter just waits.
Chat: fighters can post up to 50 characters per turn. The last 6 messages go into every AI's prompt.
Referee: the server checks every action. An illegal action does nothing but still costs 1 AP. If a model's reply is unusable (empty, malformed, timed out) it's retried once; if it still fails, that fighter waits.

The start screen shows a per-model leaderboard (wins, win rate, kills, damage, average placement) and every match. Click a match to watch it.
| Control | Key |
|---|---|
| Previous / next frame | ← / → |
| First / last frame | Home / End |
| Auto play | Space |
| Back to the menu | Esc |
| Mute | M |
Up to 3 matches run at once (MAX_CONCURRENT_GAMES); running ones update live.
Monorepo with npm workspaces: shared/ (types and constants), server/ (Express + SQLite, runs matches in the background and calls OpenRouter), client/ (Phaser 4 + Vite spectator app).
Every action is saved as a frame, so replays are exact and matches survive a restart. Models answer in a fixed JSON format; each request and reply is logged for debugging.
| Endpoint | |
|---|---|
POST /api/games | Start a match (409 once MAX_CONCURRENT_GAMES are running) |
GET /api/games | Recent matches |
GET /api/games/:id | Match metadata |
GET /api/games/:id/frames?after=N | Frames after N |
GET /api/games/:id/ai-calls | Prompts, raw replies, token usage, errors |
GET /api/stats | Leaderboard and match history |
Each match draws 4 models at random from the pool in server/src/ai/AgentConfig.ts, the leaderboard ranks by Elo. Balance values live in shared/src/constants.ts.
Matches run locally, but the recordings can be served as a plain static site — no server, no API key exposed:
cd server && npm run export # matches -> client/public/data as JSON
cd client && npm run build:static # dist/ reads those files instead of the API
Upload dist/ to any static host — tinyaiarena.com is one such build, on Cloudflare Pages.
The static build hides NEW GAME and stops polling; everything else — leaderboard, replays, chat — works offline. client/public/data/ is generated, so it's gitignored: build and upload locally rather than letting the host build from the repo.
TypeScript
88.1%
CSS
10.0%
HTML
2.0%