Docs
Get started
Quickstart
Four steps to run a match locally.
-
Clone
git clone https://github.com/strakam/generals-bots.git cd generals-bots -
Install
Two ways to install — pick one:
Option A — virtual environment (recommended). Installs the engine into an isolated
.venv, so its dependencies can't clash with other Python projects on your machine. Choose this if you work on more than one Python project — which is almost everyone.python -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate pip install -e .Re-run
source .venv/bin/activatein any new terminal to get back into the env.Option B — direct install. A single command into your current Python. Fewer steps, but the engine's dependencies mix with your global packages and can cause version conflicts. Fine for a throwaway machine or an already-isolated container.
pip install -e . -
Copy a starter, edit the strategy
cp -r competition/agents/expander_python competition/agents/my_bot # open competition/agents/my_bot/agent.py and edit Agent.act()The starter is a working baseline that expands into neighboring tiles. Only the body of one function needs to be replaced.
-
Play a match against the baseline
python competition/matchup.py \ competition/agents/my_bot/run.sh \ competition/agents/expander_python/run.sh --guiThe
--guiflag opens a Pygame window and renders the match so you can watch it play out — great for debugging, but slower because of the rendering. Drop--guito run headless: with rendering disabled the match finishes much faster, which is what you want for quick iteration or running many games. Add--mode competitionto play the exact competition ruleset — rectangular maps, castles, fog of war, and deathtouch.
Bot evaluation interface
A bot communicates with the game engine using stdin to read the board and stdout to write an action. The simplest bot passes every turn. Here is an example:
import sys
# Handshake: one line at startup → "player_id H W"
_, H, W = map(int, sys.stdin.readline().split())
while True:
scalars = sys.stdin.readline()
if not scalars: break # EOF means game over
for _ in range(3 * H): # three grids of H lines each
sys.stdin.readline()
sys.stdout.write("1 0 0 0 0\n") # pass
sys.stdout.flush() # ← do not forget this
#include <cstdio>
int main() {
int pid, H, W;
scanf("%d %d %d", &pid, &H, &W);
int t, ml, ma, ol, oa, x;
while (scanf("%d", &t) == 1) { // EOF means game over
scanf("%d %d %d %d", &ml, &ma, &ol, &oa);
for (int i = 0; i < 3 * H * W; ++i) scanf("%d", &x);
printf("1 0 0 0 0\n"); // pass
fflush(stdout); // ← do not forget this
}
}
use std::io::{self, BufRead, Write};
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let hs = lines.next().unwrap().unwrap(); // handshake "player_id H W"
let H: usize = hs.split_whitespace().nth(1).unwrap().parse().unwrap();
let out = io::stdout();
let mut o = out.lock();
while let Some(Ok(_scalars)) = lines.next() { // EOF means game over
for _ in 0..3 * H { lines.next(); }
writeln!(o, "1 0 0 0 0").unwrap(); // pass
o.flush().unwrap(); // ← do not forget this
}
}
The remaining work is to parse the grids into a suitable data structure and select a move.
Observation format
Once per turn the engine sends 1 scalar line and 3 grids of H rows × W columns each. All values are whitespace-separated integers.
turn my_land my_army opp_land opp_army <H lines of W ints> # type: what's on each cell <H lines of W ints> # owner: who owns each cell <H lines of W ints> # army: army count on each cell
Type codes
0 fog (cell not visible) 1 plain (empty passable cell) 2 mountain (impassable) 3 castle (produces army for its owner) 4 general (a player's home base) 5 structure-in-fog (castle or mountain you can't see clearly)
Owner codes (perspective-relative)
0 neutral / unknown 1 ME ← you are always "1" 2 OPPONENT
1 and the opponent is always 2, regardless of player index. No conversion is required.
Action format
One line per turn, written to stdout (and flushed):
kind row col dir split
- kind —
0to move,1to skip the turn,2to build a castle. - row, col — the cell you move from, or build on. Must be yours; a move also needs army > 1 there.
- dir —
0up,1down,2left,3right. Ignored for pass and build. - split —
0moves the whole army but one;1moves half of the army (floor). Ignored for pass and build.
Examples:
0 3 5 1 0 # move the whole army from (3,5) down 0 7 2 3 1 # move half of the army from (7,2) right 2 4 4 0 0 # build a castle on (4,4) — needs the price in army on that cell 1 0 0 0 0 # pass
Invalid moves (out of bounds, into a mountain, source not yours, source has ≤ 1 army) are silently treated as a pass, and so are invalid builds (cell not yours, not plain land, price not met). No penalty beyond the wasted turn. Castle prices and build mechanics are on the Rules page.
Training interface
For training a reinforcement-learning agent rather than a handcrafted heuristic, the same repository provides a vectorized environment built for fast iteration:
- JAX environment — jittable, with batched stepping and auto-reset for high-throughput rollouts (observations come out as arrays; convert with
numpy.asarrayif the rest of your stack is NumPy). - Same observation semantics as evaluation — the training environment exposes the same grids and scalars provided over stdio during matches. One difference to plan for: training pools pad every board to a fixed 21×21 with mountains, while graded matches are exact 18–21 rectangles (the handshake gives you the true H and W).
The environment API, usage examples, and training scripts are documented in the repository.
Submit
A submission is a zip of a directory. The only required file is run.sh, which is executed to start the bot.
my_bot/ run.sh # required: launches your bot build.sh # optional: one-time compile step at intake ... your code ...
An example run.sh for each language:
run.sh — Python
#!/usr/bin/env bash
exec python -u main.py
run.sh — C++ (compiled once by build.sh)
#!/usr/bin/env bash
# build.sh did: g++ -O2 -std=c++17 -o agent main.cpp
exec ./agent
run.sh — Rust (compiled once by build.sh)
#!/usr/bin/env bash
# build.sh did: cargo build --release
exec target/release/agent
Upload from your dashboard. We compile, sandbox, and start running matches within a couple of minutes.
Environment
The exact runtime your bot executes in. Everything on this page is verified against the live sandbox image, not aspirational — if it's listed, it works.
build.sh and the match itself run fully offline. You cannot pip install, apt install, cargo fetch, or download anything at build or run time. Everything your bot needs must either be baked into the image (tables below) or shipped inside your zip (vendoring).
Languages & toolchains
| Language | Toolchain |
|---|---|
| Python | CPython 3.12.10 — scientific stack below |
| C++ | g++ 12.2 (-std=c++17/20), make, cmake, Eigen (/usr/include/eigen3) |
| Rust | rustc / cargo 1.97 (stable) |
Other languages work too — ship a self-contained binary or a vendored runtime in your zip, started by run.sh. The engine repository's expander_python / expander_cpp / expander_rust baselines are complete working examples for the three first-class languages.
Python libraries (pre-installed)
Import any of these directly — no install step, no wheels to ship. To mirror the sandbox locally, install the pinned versions from the engine repo's competition/requirements.txt:
pip install -r competition/requirements.txt
| Library | Version |
|---|---|
numpy | 2.4.6 |
scipy | 1.18.0 |
pandas | 3.0.5 |
scikit-learn | 1.9.0 |
torch | 2.13.0 (CPU) |
jax | 0.11.0 |
numba | 0.66.0 |
networkx | 3.6.1 |
safetensors | 0.8.0 |
gymnasium | 1.3.0 |
Not installed: TensorFlow, CUDA builds of anything, torchvision/torchaudio, RL training frameworks (stable-baselines3, RLlib). The match container runs inference — train on your own hardware and submit the trained weights.
Compute & limits
- CPU only, one dedicated core per bot. No GPU at match time — use CPU builds of ML libraries.
- Memory: hard 2 GB cap per bot. Exceeding it crashes your process (a forfeit); it can never affect your opponent.
- Size: zip ≤ 50 MB; unpacked ≤ 512 MB and ≤ 10,000 files.
$HOMEis your own writable directory — safe for~/.cache,~/.cargo, on-disk JIT caches, etc.
Bringing your own dependencies
Because there is no network at build time, extra dependencies ship inside the zip and install offline in build.sh:
- Python — put wheels in
./wheels/and runpip install --no-index --find-links=./wheels -r requirements.txt. Wheels must be Linux (manylinux), CPython 3.12. Mind the 50 MB zip cap — this is exactly whytorchis pre-installed rather than vendored. - Rust — run
cargo vendorlocally, include thevendor/directory and.cargo/config.toml, thencargo build --release --offline. - C++ — header-only libraries drop straight into your tree (Eigen is already provided).
Startup cost
import torch costs ~2.3s and the full scientific stack ~3s of your 10-second first-move budget; a JAX or torch model's first forward pass triggers JIT compilation then, too. Warm what you can in build.sh — but note an in-memory JIT cache does not survive from build.sh to run.sh, so persist caches to disk inside your tree.
The competition ruleset
Matches run under one pinned ruleset — locally it's --mode competition on the runner:
- Rectangular maps, each side 18–21 — read the size from the handshake, never hard-code it.
- Fog of war — like the original generals.io, you see only the cells next to tiles you own, so type codes
0(fog) and5(structure-in-fog) do appear; everything beyond your vision is hidden. - No neutral castles — you build your own with the
2 r c 0 0action (pricing on the Rules page). - Deathtouch from turn 800, and a 1200-turn cap — see Rules for the exact semantics.
Common pitfalls
- Flush stdout after every action. Pipes are fully buffered by default. Without a flush, the runner does not receive the move and the game deadlocks. (
python -uorsys.stdout.flush();fflush(stdout);.flush().) - EOF means game over. stdin closes when the match ends — there is no "winner" line. An empty result from
readline()indicates the bot should exit. - Owner is relative to the bot. Owned cells are
1and opponent cells are2, regardless of player index. Remapping is unnecessary. - Invalid moves do not crash. They are treated as a silent pass. Local testing is recommended to avoid wasted turns.