Skip to content

Repository files navigation

Nidozo

CI

An arena where two LLMs compete in Pokémon battles. The battle engine is Pokémon Showdown, accessed via poke-env. Models reason over legal actions each turn, pick their move, and an ELO system tracks skill over time.

Battles use Gen 9 National Dex as the canonical ruleset — any Pokémon from any generation can fight using any move it can legally learn today. Showdown validates teams automatically, so there's no per-generation moveset maintenance.

Sibling project to Nimzo (the LLM chess arena).


Features

  • Gen 9 NatDex battles — Cross-gen: any Pokémon from any generation with any legal move; Showdown is the authority on legality. Random and drafted team formats; fully rules-correct via a local Showdown server
  • Tier formats — Random / OU / UU / LC / Ubers / Freeforall, all backed by gen9nationaldex* Showdown formats; tier badges throughout the UI
  • Configurable team size — full 6v6, plus 3v3 singles and 4v4 doubles variants with their own Showdown formats
  • Doubles battles (2v2/4v4) — opt-in doubles=true runs a Showdown doubles format with full target selection (foe_1/foe_2/ally/self); prompt v7, action parser, and heuristic engine handle spread moves and partner synergy
  • Drafted teams — LLM snake-drafts a team from 513 Pokémon with Gen 9 NatDex competitive sets (sourced from Showdown's factory data + synthesised randbat sets); DraftPhase UI with animated pick reveal
  • Team import — paste a standard Showdown export to battle with your exact team (per player; runs in Anything Goes so any legal team is accepted) — see whether an LLM can pilot your team
  • Pluggable LLM backends — Anthropic, OpenAI, local model via LM Studio, or human player (select human as provider to play one side yourself from the browser — a move/switch picker overlays the battle stage on your turn)
  • Multi-agent coach mode — an optional second "coach" model deliberates alongside the player model before each decision, using a dedicated coach prompt set
  • JSON structured outputs — models respond with {"reasoning":"…","action_type":"move","identifier":"thunderbolt"}; grammar-sampled on OpenAI/LM Studio backends for near-certain parse reliability. The default prompt is v9 (see Prompt versions)
  • Heuristic advisory — type effectiveness, estimated damage (accuracy-adjusted), speed-tier awareness, entry-hazard chip costs, weather modifiers, switch quality scoring, low-PP warnings, battle-context block — all surfaced as advisory context (non-binding)
  • Hidden-information enforcement — each model sees only what a human player would legitimately know
  • Cross-battle memory — after each battle the LLM generates a short lesson; lessons are stored per model and injected into future system prompts so models adapt strategy over time. A per-battle toggle plus a per-model Lesson Efficacy panel (win rate with lessons on vs off) let you actually measure whether the learning loop helps
  • Personality profiles — per-model play-style profiling derived from decision history
  • ELO rankings — updated after every battle, persisted in SQLite; leaderboard with tier filter tabs
  • Token & cost analytics — per-turn token usage (player + coach) is recorded; the Global Stats page shows per-model and total tokens with estimated USD cost from an editable price table (data/model_prices.json); local models count as free
  • Per-model stats page — W/L/T history, ELO sparkline, opponent breakdown, decision-quality distribution, lesson log
  • Tournaments — round-robin plus single- and double-elimination brackets; live progress, standings/bracket overlay, battle cancel; full history page. Available from the UI or the CLI
  • Seasons — multi-round campaigns that aggregate standings and ELO across many battles
  • Bake-off experiments — pit two variants of the same base model (different provider, model, or prompt version) head-to-head over N battles with alternating sides; the result reports win-rate plus an exact binomial significance test, so you can tell whether "prompt v9 beats v8" is real or noise
  • Battle Replay — step through any completed battle turn by turn; HP timeline; scrub/keyboard nav; auto-play
  • Post-game analysis — decision quality (optimal/good/suboptimal/fallback), blunder detection, win-probability timeline, turning-point detection, RNG inference; key moments list (clickable, seeks replay); variance report (crit/miss tally with per-player benefit counts); draft critique (STAB coverage, shared weaknesses, execution quality)
  • Live visualizer (Showdown cockpit) — the battle view: the built-in Pokémon Showdown battle scene (sprites, animations, HP bars, scene background) centred in a cockpit, with Nidozo's analytical panels around it — model labels, win-probability bar, heuristic advisory (move scores + type badges + PP), thinking indicators, cancel control, and the full battle log
  • Responsive UI + theme toggle — mobile-friendly layout and a light/dark theme toggle, persisted across sessions
  • Containerised — the whole stack runs with a single docker compose up (see Quick start)

Quick start — Docker (recommended for servers)

The entire stack runs with a single command. Docker is the recommended way to run Nidozo on a server (e.g. a headless Linux/Windows+WSL2 machine).

git clone https://github.com/haggyroth/nidozo.git
cd nidozo
docker compose up -d

Open http://<host>:5001 — the React SPA is served directly by FastAPI.

  • Showdown runs on host port 8001 (exposed for the cockpit view and debugging; internally it's 8000, mapped to 8001 to avoid colliding with other services on shared hosts).
  • API + frontend runs on port 5001.
  • SQLite data persists on a named Docker volume (nidozo-data) and survives container restarts.

To stop and remove containers (data preserved):

docker compose down

To also wipe the database volume:

docker compose down -v

First build takes a few minutes — the Showdown source is compiled (node build) and the Python/Node dependencies install once; subsequent restarts are fast.

Running actual battles needs an LLM backend reachable from the api container — set cloud API keys (e.g. ANTHROPIC_API_KEY) in the api service environment, or point it at an LM Studio server. Two random bots work with no backend at all.

Configuration & authentication

The api service reads these environment variables (pass them in the host shell, or via a .env file next to docker-compose.yml — Compose loads it automatically):

Variable Purpose Default
NIDOZO_API_TOKEN Shared-secret token. When set, every /api/* request and WebSocket requires it. When unset, auth is disabled and a warning is logged at startup. (unset → auth off)
NIDOZO_RATE_LIMIT_PER_MIN Max battle/tournament/season/experiment start requests per minute per client IP (returns 429 over the limit). 0 disables it. 0
LM_STUDIO_BASE_URL OpenAI-compatible URL of your LM Studio server. http://localhost:1234/v1
LM_STUDIO_MODEL Default LM Studio model id. local-model
ANTHROPIC_API_KEY / OPENAI_API_KEY Cloud LLM keys, used only for those providers. (unset)

Authentication (#212). /healthz and the static web app always stay open (so health checks work and the page can load); everything else is gated when NIDOZO_API_TOKEN is set. In the browser, click the 🔑 button in the header and paste the token — it's stored in localStorage and attached to every request automatically (you'll also be prompted automatically on the first 401). Programmatic clients send Authorization: Bearer <token> (WebSockets use a ?token=<token> query parameter, since browsers can't set headers on a WS handshake).

⚠️ If you expose port 5001 beyond localhost, set NIDOZO_API_TOKEN. The battle-start endpoints spend real LLM API credits, so an open, internet-reachable instance is a money-drain risk.

Example: multi-machine deployment

A split setup — Nidozo on a server, LLMs on a workstation, viewed from a laptop:

  • Server (runs the container stack) — create a .env next to docker-compose.yml:
    NIDOZO_API_TOKEN=choose-a-long-random-secret
    LM_STUDIO_BASE_URL=http://<workstation-host>:1234/v1
    # ANTHROPIC_API_KEY=...   # only if using cloud models
    then docker compose up -d.
  • Workstation — run LM Studio with its server enabled and listening on the network (not just 127.0.0.1) so the container can reach it.
  • Laptop — open http://<server-host>:5001, click 🔑, and paste the same token.

See docs/DEPLOYMENT.md for the full pre-flight checklist, smoke test, and troubleshooting.


Prerequisites (local / dev setup)

Tool Version Install
Python 3.12 brew install [email protected] or via uv
Node.js 22.12+ brew install node
uv any brew install uv

Node 22.12+ is required: Vite 8 needs 20.19+/22.12+, and the vendored Showdown build script requires Node 22+.


Local / dev setup

1. Clone the repo and install Python dependencies

git clone https://github.com/haggyroth/nidozo.git
cd nidozo
uv venv --python 3.12
uv pip install -e ".[dev]"

2. Install Showdown dependencies

The Showdown server source is vendored in showdown/. Install its Node dependencies and create the local config once:

cd showdown
npm install
cp config/config-example.js config/config.js
cd ..

Why --no-security? poke-env connects as bots with generated usernames. --no-security disables the login challenge so bots can connect freely to the local server. scripts/start_showdown.sh passes this flag automatically (and creates config/config.js for you if it's missing).

3. (Optional) Set up LM Studio for local models

Install LM Studio, load a model, and start the local server on port 1234. The UI will auto-discover loaded models via the /v1/models endpoint.


Running battles (local dev)

# Terminal 1 — Showdown server
./scripts/start_showdown.sh

# Terminal 2 — API + WebSocket server (port 5001)
uv run python scripts/serve.py

# Terminal 3 — React frontend (port 5173, proxies /api and /ws to 5001)
cd frontend && npm run dev

Open http://localhost:5173, select models, and click ▶ START BATTLE to watch turn by turn. Use ⚔ TOURNAMENT to run a round-robin or elimination bracket across multiple models. Completed battles show ▶ REPLAY and ▼ ANALYZE buttons in the Recent Battles panel.

The battle view — Showdown cockpit

The live battle renders in the Showdown cockpit: the built-in Pokémon Showdown battle scene (the same animated renderer used on play.pokemonshowdown.com) centred in a cockpit, with Nidozo's analytical panels around it — cancel control, win-probability bar, heuristic advisory, thinking indicators, winner banner, and tournament/season progress.

Requirements:

  • The Showdown server must be started with --no-security (the default in start_showdown.sh) so the spectator proxy can connect as a guest.
  • Sprite and sound assets are loaded on demand from play.pokemonshowdown.com (~4 MB, CDN). An internet connection is required the first time; subsequent views use the browser cache.

A light/dark theme toggle sits in the app chrome; the preference is saved in localStorage and restored on reload.

Tournament runner (CLI)

uv run python scripts/tournament.py \
  --player lmstudio:ibm/granite-4-h-tiny \
  --player lmstudio:mistralai/ministral-3-3b \
  --rounds 3

Each model pair plays both sides each round. Results are persisted to nidozo.db and an ELO table is printed at the end.

Single battle (CLI)

# Two random bots (no API key needed)
uv run python scripts/run_battle.py

# LLM vs random
ANTHROPIC_API_KEY=sk-... uv run python scripts/run_battle.py --p1 anthropic

# Local model via LM Studio
uv run python scripts/run_battle.py --p1 lmstudio --model "ibm/granite-4-h-tiny"

Leaderboard (CLI)

uv run python scripts/leaderboard.py    # print the current ELO table

Project structure

nidozo/
├── src/nidozo/
│   ├── api/            FastAPI app, EventBus, WebSocket feeds, REST endpoints,
│   │                   battle/tournament/season orchestration
│   ├── analysis/       Post-game annotator: decision quality, blunders, RNG,
│   │                   draft critique, variance report, narrative
│   ├── battle/         LLMPlayer, StreamingPlayer, ActionParser, heuristics,
│   │                   serializer, draft, team_builder, presets, tiers
│   ├── tournament/     Bracket builder (round-robin, single/double elimination)
│   ├── db/             BattleStore (SQLite), ELO, schema migrations
│   └── llm/            ModelBackend protocol, AnthropicBackend, OpenAIBackend,
│       │               lesson_generator, coach, prompt_builder
│       └── prompts/
│           ├── v1 … v9/  Versioned prompt sets (v9 default; v7 doubles; v3 draft)
│           └── coach/     Multi-agent coach prompt set
├── data/
│   ├── natdex_movesets.json  513 species with Gen 9 NatDex competitive sets
│   ├── gen3_movesets.json    legacy Gen 3 sets
│   └── party_presets.json    curated preset teams
├── frontend/           Vite + React live battlefield visualizer (Showdown cockpit)
├── scripts/
│   ├── serve.py              uvicorn entrypoint (port 5001)
│   ├── tournament.py         Round-robin / bracket CLI runner
│   ├── run_battle.py         Single-battle CLI
│   ├── leaderboard.py        Print the ELO leaderboard
│   ├── build_natdex_sets.py  Regenerate natdex_movesets.json from Showdown data
│   ├── start_showdown.sh     Start the local Showdown server
│   └── stop.sh               Stop dev processes
├── tests/              1021 unit tests + 1 integration test (pytest.mark.integration)
├── showdown/           Vendored Pokémon Showdown server (compiled at build time)
├── Dockerfile          Multi-stage API image (Vite build → FastAPI)
├── docker-compose.yml  Two-container stack: showdown + api
└── docs/               Architecture notes (OP-02 Showdown battle scene, etc.)

Prompt versions

Prompts are versioned so changes can be correlated with ELO shifts. All use Gen 9 NatDex mechanics. The default is v9; the right version is selected automatically (doubles → v7, drafted battles → v3).

Version Notes
v9 Default. v8 + speed-tier annotation inline in the bench summary
v8 Entry-hazard awareness (Stealth Rock / Spikes / Toxic Spikes / Sticky Web chip costs)
v7 Doubles. 2v2/4v4 turn template with target selection
v6 Heuristic battle-context block (KO risk, phase, status impact)
v5 Full decision framework: survival check → KO check → matchup → switch value
v4 Structured reasoning with battle history + threat map
v3 Draft-aware. Team roster + draft context in the system prompt (auto-used for drafted battles)
v2 JSON: {"reasoning":"…","action_type":"move","identifier":"thunderbolt"}
v1 Legacy text: ACTION: move thunderbolt
coach Multi-agent coach prompt set (used with coach mode)

Pass --prompt-version vN to the CLI runners, or prompt_version in the API request, to override the default.


Troubleshooting

ConnectionRefusedError when running a battle Showdown isn't running. Start it: ./scripts/start_showdown.sh (local dev) or docker compose up showdown (Docker).

ModuleNotFoundError: nidozo Run uv pip install --reinstall-package nidozo -e ".[dev]" to regenerate the editable install .pth file.

Models returning empty responses Check LM Studio is running and the model is loaded. The server retries once automatically and logs the finish_reason on failure.

Showdown EADDRINUSE 8000 A previous Showdown process is still running. Kill it: pkill -f pokemon-showdown

Node version issues Node 22.12+ is required (Vite 8 and the Showdown build script). Check with node --version.


See also

About

An LLM-driven competitive play framework using the Gen 9 Pokémon Showdown engine. Draft, battle, run tournaments, and track model ELO over time.

Topics

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages