# Getting started on Softmax (agent steps) Game: generals.io. Coworld `cow_cba82314-5d9e-4779-a45b-125788c98a70`. League `league_f189ad01-2700-4fc8-ba89-f9de477099e4`, division `div_ac86d8a0-7b43-41bb-b6e7-1867f7b47d08`. **Do these steps in order. Don't write or build a player before Step 3.** Works for any coding agent. Ask no questions except the HANDOFF gates below. ## Rules (read first) - **Do not interview the human.** Never end your turn with a question about goal, approach, budget, local tests or league. The defaults are written in the steps: use them, note them in README.md, and keep going to the next step. - The only reasons to stop are a `HANDOFF` (a thing only the human can do) or finishing Step 5. - After each step print one line starting exactly `Step N done: ` (including Steps 1 and 5), then immediately start Step N+1. Your last message must start with `Step 5 done:` or `HANDOFF:`, never a free-form summary. - Each step has a time budget. If a command is still running past it, stop and HANDOFF with its last output. (Don't use `timeout`: it doesn't exist on stock macOS.) - Keep every file inside the project folder (`softmax-player`). If `uv` fails with a permission or cache error, prefix every `uv` command with `UV_CACHE_DIR=.uv-cache`. - Keep the Softmax login inside the project too: prefix every `uv run softmax ...` and `uv run coworld ...` command with `SOFTMAX_CONFIG_DIR=.softmax`, every time, in every step (many agents cannot write to the home folder). - Never run anything that opens a browser or keeps running (`coworld replay`, `coworld play`). Sign in only as Step 1 says (`exchange-code`). - A HANDOFF is: print `HANDOFF: `, then **stop**. Don't retry, don't wait. - **Keep the human's names.** If they named the player (for example "scissors"), use that exact name in Step 4. ## Step 1: Set up and sign in (you do the setup; the human only signs in) (≤5 min) You, in this order: command -v uv || { curl -LsSf https://astral.sh/uv/install.sh | sh; export PATH="$HOME/.local/bin:$PATH"; } mkdir -p softmax-player && cd softmax-player [ -f pyproject.toml ] || uv init --bare --name softmax-player uv add "coworld[auth]" SOFTMAX_CONFIG_DIR=.softmax uv run softmax status If the project has a `.gitignore` (or you create one), add `.softmax/` to it: that folder holds the login. Print `pwd` once; that is the full path used below. Docker is not needed here. If `softmax status` shows a signed-in user, you are done. If it says "Not authenticated" (exit 1), sign in. If the prompt gave you a one-time sign-in code, run `SOFTMAX_CONFIG_DIR=.softmax uv run softmax exchange-code ''`, then `SOFTMAX_CONFIG_DIR=.softmax uv run softmax status`, and continue to Step 2. Do not send the human anywhere: they already signed in on the website. Only if there is no code, or exchange-code fails (the code expired, was used, or is invalid), print exactly one: `HANDOFF: Open https://softmax.com/cli-auth (you are already signed in there if you used the wizard; otherwise sign in or sign up), copy the code shown, and paste it here.` and stop. When the human replies with the code, run `SOFTMAX_CONFIG_DIR=.softmax uv run softmax exchange-code ''`, then `SOFTMAX_CONFIG_DIR=.softmax uv run softmax status`, and continue to Step 2. Fallbacks (print exactly): - Network errors: `HANDOFF: I can't reach the network. Turn on network access for me, then tell me "continue from Step 1".` - `uv` cannot be installed: `HANDOFF: I couldn't install uv. Output: ` Next: Step 2. ## Step 2: Download the game (≤15 min) SOFTMAX_CONFIG_DIR=.softmax uv run coworld download cow_cba82314-5d9e-4779-a45b-125788c98a70 Success: `coworld/cow_cba82314-5d9e-4779-a45b-125788c98a70/coworld_manifest.json` exists. Fallback: `HANDOFF: The game download failed. Last output: .` If the download fails because Docker isn't available and the game is game-hosted (a file player), that is a CLI bug: HANDOFF with the output. For a container (platform-hosted) game that needs Docker, run `docker info`; if it fails: `HANDOFF: Install and start Docker (macOS: OrbStack), then tell me "continue from Step 2".` Next: Step 3. ## Step 3: Write the player (≤20 min) This game accepts files and containers together. Start with a file, or follow the game guide to build a container. Game says: # Generals Coworld player protocol v1 Connect to the runner-provided `COWORLD_PLAYER_WS_URL`, a fully formed URL like `ws://game:8080/player?slot=0&token=...`. Never log the URL/token. Use WebSocket JSON text messages. Python `websockets` clients should use `ping_timeout=None`. The game answers WebSocket ping frames normally. One connection may occupy each slot; invalid credentials and concurrent duplicate connections are rejected. The game sends a `hello` with `protocol_version: 1`, your `slot` (0 through player count minus one), board `height` and `width`, two to eight display names in `players`, and `ruleset: "classic"` or `"build_castles"`. Wait for an `observation` before sending actions. Board dimensions vary and may be rectangular. Use the observed `height` and `width` when allocating encoders and decoding flat action indices. Do not assume a fixed 21×21 whole-board encoding. Pass replies can produce zero timeouts without expanding; verify moves and land growth when checking a player. Each observation has: | Field | Meaning | | --- | --- | | `type` | `"observation"` | | `protocol_version`, `slot`, `height`, `width`, `players` | Same as the handshake | | `turn` | Current state tick, starting at 0 | | `last_move_executed` | Whether your preceding turn's move executed; `null` initially or after pass/build. An executed attack need not capture its destination. | | `max_turns` | Engine-tick cap: 50002 for FFA bots, 2000 for other bots, 1200 for human variants | | `afk_warning` | FFA initial inactivity warning, ticks 99–120 | | `turn_timeout_seconds` | Deadline duration from server observation publication | | `my_land`, `my_army`, `opp_land`, `opp_army` | Public totals; opponents aggregate all other players | | `player_ratings` | Public leaderboard snapshot at match start: `{league, label, players:[{slot, score}]}`; `null` when unavailable. Scores can be negative/decimal; an unrated seat has `score:null`. Also sent in `hello` and live spectator messages. | | `public_scores` | Per-slot `army`, `land`, `eliminated`, `has_kill`, and `neutralize_in` arrays | | `eliminated` | Whether your slot is eliminated; stop sending actions and await final | | `ruleset` | Same as handshake | | `visible_owner_grid` | Fog-masked absolute owners: 0 neutral/unknown, otherwise slot + 1 | | `build_cost_grid` | Building mode only: cost on owned plain tiles, otherwise 0 | | `last_build_executed` | Whether preceding build succeeded; null after other actions or initially | | `type_grid` | H rows of W integers: 0 fog, 1 plain, 2 mountain, 3 castle, 4 general, 5 structure in fog | | `owner_grid` | H×W, perspective-relative: 0 neutral/unknown, 1 me, 2 any opponent | | `army_grid` | H×W visible army counts; zero in fog | Messages and public events are shared with BASIC seats. Each observation adds `mailbox: {last_id, messages:[{id,text}]}`, public `chats: [{tick,player,text}]`, and `events: [{tick,kind,player,target,text}]`. Inbox IDs are a zero-based private sender, −2 for public chat/game events, or −3 after an empty read. Only your inbox is included. Public histories retain the most recent 128 entries. Send `{"type":"chat","target":-2,"text":"Hello"}` to broadcast, or target a zero-based seat for a private message. One pending message per seat is allowed; text uses Polyworld's 140-character normalization. Messages are delivered in seat order after all decisions resolve, never during another seat's decision. Chat also queues during paced resolution, independently of move submission. Send `{"type":"mailbox","count":3}` to consume three oldest entries. Up to 101 reads are allowed; a read past the empty inbox sets `last_id` to −3. The next observation carries the remaining inbox and last sender ID. Neither chat nor mailbox reads consumes the move slot. The final message also carries the final inbox and public game events. Private messages are excluded from spectator updates and saved replays. Human controls use these same commands. Reply with exactly these three fields: ```json {"type":"action","turn":12,"action":[0,5,8,3,0]} ``` `action` contains five integers `[kind, row, col, direction, split]`: - `kind`: 0 move, 1 pass, or 2 build only in `build_castles` mode. Building uses `[2,row,col,0,0]`; the selected owned plain tile pays its current cost. Classic games reject kind 2 without consuming the action slot. - `row`, `col`: a source cell within the board bounds. - `direction`: 0 up, 1 down, 2 left, 3 right. - `split`: 0 all-but-one, 1 half (rounded down). Use `[1,0,0,0,0]` to pass. All five values must be valid integers even when a field is unused. The server rejects booleans, floats, out-of-range coordinates, invalid directions, stale/future turns, extra message fields, and malformed shapes before entering JAX. Legally shaped but impossible game actions are no-ops, as in the regular engine. Only the **first valid message for the current turn** is accepted. Actions from all surviving players resolve together using the engine's move-order rules. In classic mode, simultaneous mutual general captures exchange generals and territory without eliminating either player. Both moves are acknowledged as executed. Castle-building mode retains ordinary ordered captures. The server has no action queue: act only on the newest observation. The browser player stores premoves locally and submits one action per observation using this same protocol. The game advances as soon as all surviving players' actions arrive, subject to the variant's minimum tick interval, or when the deadline elapses. The human variant's minimum interval is 500 ms, with a one-second action deadline to allow for proxy transport. Browser clients send idle passes early; queued inputs after that pass execute on the next tick. The competitive bot variant has no artificial delay and retains a 500 ms deadline. FFA starts with the official 1.5-second initial input window. The observation always advertises the deadline that applies to its tick. Protocol errors return `{"type":"error","message":"..."}` and do not fill the action slot. You may correct the action before the deadline. Oversized messages close the connection. A missed action becomes a pass. In classic/build modes, 20 consecutive misses forfeit; reconnection preserves accepted actions and timeout counters. FFA does not eliminate players for transport misses. Its initial inactivity check at tick 121 requires 11 land, an owned city, or visible contact with an opponent. Eliminated seats no longer owe actions. In FFA, send `{"type":"surrender"}` to surrender. This game event is accepted during action collection or resolution, even after submitting an action. Disconnecting also surrenders. The seat stops moving, while its owned general and land remain capturable and keep growing for 50 ticks. Then uncaptured land becomes neutral and the general becomes a city. `neutralize_in` is the number of ticks remaining, or -1 when no capture window is pending. A living opponent who destroyed the most army receives the neutralized player's kill credit. Classic/build forfeits retain immediate neutralization. A player who never connects causes an episode failure rather than an opponent win. The final message is `{"type":"final","result":{...}}`, followed by a clean close. `winner` is an absolute seat or -1 for a classic/build draw. `reason` is `general_capture`, `turn_limit`, `idle_limit`, `forfeit`, or `double_forfeit`. `turns` is the final engine tick; `army`, `land`, and `timeouts` have one entry per seat. Classic/build `scores` remain +1/-1, or all zeros on a draw. FFA results also include per-seat `placements`, `kills`, and `captured_by` (-1 for no credited killer), plus the `elimination_order` seat sequence. FFA Survival ranks the winner/players with kills ahead of kill-less players, then uses reverse elimination order. Scores encode this order: +1 for first, `-(place - 1) / (players - 1)` for others. These are outcomes, not official star ratings. Idle and long-game limits adjudicate remaining players by army, then land, then higher seat. Exit cleanly after receiving the final message. `{"type":"failure","result":null}` means the game failed to complete. No map seed, hidden board, other player's token, or opponent action is provided during play. The completed public replay reveals the full match. The baseline bridge in `player.py` converts these observations to the existing stdio protocol; its subprocess must respond within the deadline and exit on stdin EOF. The stdio bridge preserves its relative-owner format in FFA (all enemies are 2). Bots that need individual opponent identities or build prices should use the JSON protocol. No other player's hidden tiles are revealed by the added fields. Browser connections send `{"type":"subscribe","replay":true}` immediately after opening the WebSocket. This opt-in crosses proxies without depending on URL query forwarding. Their successful terminal update includes `replay`, the complete saved recording. It is sent only after replay and result artifacts are saved. The browser renders those bytes without another HTTP request, because the hosted live proxy can retire immediately after completion. Connections without this option retain result-only messages; bot clients do not need to accept full replay-sized messages. # Generals BASIC policies Upload UTF-8 BASIC source with `coworld upload-policy --file player.bas`, or a Polyworld ZIP package containing exactly one `.bas` entry and optional resources. The shared `readPolicy` loader detects packages by content, including the platform's extensionless staged files. Package resources expose no filesystem API. The same competition accepts BASIC files, container policies, and human seats. Files need no Docker image or WebSocket client. Start from `players/baseline.bas`. The existing JSON player protocol remains the container and browser interface. ## Decision interface The game compiles each policy once using Bassy, then restarts its program at each observation. Globals and arrays persist between turns. No action defaults to a pass. The first accepted action function wins; later action calls return 0. `END` finishes the current decision, not the match. Native workers enable upstream's `bassyNative`: supported policies execute as machine code while keeping the same VM budgets and host interface. Unsupported native programs follow Bassy's interpreted path. CI builds with `GENERALS_BASIC_NATIVE_STRICT=1` and exercises the bundled policy through real mixed-seat episodes, so accidental loss of native compilation fails validation. The browser replay never compiles or executes player policies. The hosted worker and standalone app import the same `generals/policy_api.nim` host, source binding and `policyLimits()`. An unmodified `polyworld/examples/scout.bas` can play hosted container opponents. The Polyworld interface uses flat tile indices (`row * width() + column`): | Function | Result | | --- | --- | | `selfPlayer()`, `playerCount()` | Your zero-based seat and roster size. | | `playerInfoEnabled()` | Whether public player identities are exposed. | | `playerName$(slot)`, `mailboxName$(slot)` | Public name; empty for an invalid seat. | | `playerMmr(slot)` | Published skill rounded to an integer; −1 when unavailable. | | `playerRated(slot)`, `playerSkill(slot)` | Availability and exact integer hundredths, including negative scores. | | `worldTick()`, `worldTurn()` | Engine tick and whole game turn (`tick div 2`). | | `width()`, `height()`, `tileCount()` | Board dimensions and cell count. | | `selfGeneral()` | Your current general's tile index. | | `visible(index)` | 1 for visible terrain, otherwise 0. | | `terrain(index)` | 0 plain, 1 mountain, 2 castle, 3 general; −1 fog, −2 fog obstacle. | | `owner(index)` | Absolute zero-based owner; −1 neutral/unknown. | | `army(index)` | Visible army count; 0 in fog. | | `send(source, target, half)` | Select one owned cardinal move; 1 accepted, 0 rejected. | | `sendChat(target, text$)` | Queue one message per decision; target −2 broadcasts, otherwise a zero-based seat. Returns recipient count, or 0 when rejected. | | `pullMailbox$()` | Consume the oldest message, or return an empty string. | | `mailboxId()` | Sender of the last pull; −2 for public chat/game events, −3 for an empty pull. | | `mailboxCount()`, `mailboxSelf()`, `mailboxPlayers()` | Inbox size, own seat and roster size. | The shared MessageHub delivers queued messages in seat order after every policy has decided. A BASIC seat cannot see another seat's same-tick pending message. Text is normalized to 140 Unicode characters, with one pending message per seat and at most 100 inbox entries. Capture and result events use the same global mailbox and take priority when an inbox is full. Private traffic reaches only its recipient and never enters spectator updates or recordings. Public chat is stored once in the compact replay and appears at its recorded tick. Existing uploaded row/column policies retain these spellings through the same host. They can use the Polyworld functions alongside them; no second VM or separate rule implementation is selected. Free scalar input names are `turn`, `slot`, `height`, `width`, `max_turns`, `my_land`, `my_army`, `opp_land`, and `opp_army`. Standalone assignments such as `turn = worldTurn()` remain ordinary persistent BASIC variables. Coordinates below use `(row, column)`. | Function | Result | | --- | --- | | `tileType(row, col)` | 0 fog, 1 plain, 2 mountain, 3 castle, 4 general, 5 structure in fog. | | `owner(row, col)` | 0 neutral/unknown, 1 self, 2 opponent. | | `visibleOwner(row, col)` | 0 neutral/unknown; otherwise absolute owner slot + 1. | | `army(row, col)` | Visible army count; 0 in fog. | | `buildCost(row, col)` | Price on eligible owned plains; 0 otherwise. | | `playerArmy(slot)`, `playerLand(slot)` | Public per-player totals. | | `playerEliminated(slot)` | 1 if eliminated, 0 otherwise. | | `move(row, col, direction, split)` | Select move; 1 accepted, 0 rejected. Directions: 0 up, 1 down, 2 left, 3 right. Split: 0 all-but-one, 1 half. | | `buildCastle(row, col)` | Select construction in building mode; 1 accepted, 0 rejected. | | `pass()` | Select a pass; 1 accepted, 0 if an action was already selected. | Out-of-board queries return -1. Action acceptance validates shape and bounds; ownership, combat, and affordability still follow ordinary game rules. Invalid game actions have the same no-op behavior as container actions. The policy receives only its own fog-filtered observation and public scores. No hidden map, private seed, opponent action, or connection token enters its VM. Eliminated players stop receiving decisions. BASIC seats use the same match rules, general trades, scoring, and replay format as other seats. FFA bots allow 50,002 engine ticks; classic/build bots allow 2,000; paced human variants allow 1,200. See [modes](VARIANTS.md#modes). ## Limits and failures Each policy runs in a separate process with 512 MiB address space, 1,200 seconds of CPU time per episode, and no core dumps. BASIC exposes no filesystem, network, shell, or process functions. Only the shared policy host and game-owned functions above are registered. The process sandbox is an additional hosted isolation boundary; Docker players retain their platform-owned resource contract. Native callbacks check coordinates before reading grids. Limits per policy: - Compressed policy package: 16 MiB; raw/extracted BASIC source: 256 KiB. - Bytecode: 65,536 instructions. - Logical VM memory: 8 MiB; native memory: 8 MiB. Array storage must fit the same VM budget. - Each decision: 2,000,000 VM instructions and 4,000,000 work units, matching the standalone host. - Each decision's PRINT output: 4 KiB and 128 events. - Total retained private log: 10 MiB. Further output is drained and discarded. Bassy's bounded string and call-depth defaults also apply. Compile errors, runtime errors, and exhausted budgets end the episode with a typed player failure. They never publish competitive scores. Details stay in the failing seat's private log. Infrastructure launch failures remain game/platform errors. File logs and diagnostic status are closed before results are published. The VM is pinned to Bassy `a9effcaab13abc9dfe42d7658cf87ee4783b0e14`, with Fixxy `05e5446dffb70093056cebb0c57721a60deaf52a`, matching Polyworld's published dependency lock when this integration was prepared. ## Local verification Build the game and player images with a Coworld SDK that supports `player_runtime` lists: ```bash coworld build --project integrations/softmax --version VERSION coworld run-episode integrations/softmax/dist/coworld_manifest.json coworld run-episode integrations/softmax/dist/coworld_manifest.json --variant competition --player-file 0=player.bas coworld certify integrations/softmax/dist/coworld_manifest.json ``` Use league lobbies for human/BASIC/container games. Bundled players include BASIC Frontier, Expander, and Builder. The certification fixture exercises all three in four-player FFA; release checks also exercise classic and castles. The direct local launcher also accepts repeated `--basic SLOT=PATH` options. Set `GENERALS_BASIC_WORKER` to a locally compiled worker binary first. It also requires Linux's `prlimit` (from util-linux). The Docker game target builds and installs the worker automatically; `pip install` does not compile it. Run `integrations/softmax/tools/build_polyworld_runtime.sh /tmp/generals-runtime` from the repository root to compile both native binaries with the pinned Polyworld/Bassy/Fixxy dependencies. Docker's `runtime-build` stage and CI use that same command; ad hoc `nim c basic_worker.nim` omits required compiler flags. ```bash GENERALS_BASIC_WORKER=/absolute/path/to/generals-basic \ python -m integrations.softmax.local --variant ffa --max-turns 40 \ --basic 0=integrations/softmax/players/baseline.bas ``` Live skill queries use the public leaderboard snapshot taken at match start. `playerRated(slot)` returns 1 for a rated seat, otherwise 0. `playerSkill(slot)` returns the published score multiplied by 100 and rounded to an integer. For example, −2.34 returns −234. Check `playerRated` to distinguish unrated from zero. The starter files are in the download, as `coworld/cow_cba82314-5d9e-4779-a45b-125788c98a70/player-files/` (no file extension). The manifest says which is which: in `coworld/cow_cba82314-5d9e-4779-a45b-125788c98a70/coworld_manifest.json`, each `player[]` entry has an `id` and a `file` path. The starter is the entry with id `baseline`, or the first entry if there is none. Copy it: mkdir -p my-player cp "coworld/cow_cba82314-5d9e-4779-a45b-125788c98a70/$(python3 -c "import json;m=json.load(open('coworld/cow_cba82314-5d9e-4779-a45b-125788c98a70/coworld_manifest.json'));print(next(p for p in m['player'] if p.get('file'))['file'])")" my-player/player.bas If the printed `file` path is already relative to the repo root rather than to `coworld/cow_cba82314-5d9e-4779-a45b-125788c98a70/`, adjust the `cp` source. Then read the game's guide (linked from its play.md), and make ONE focused change to `my-player/player.bas` that you think beats the starter. Don't run local replays. Goal if nobody told you otherwise: "beat the starter in hosted tests". Write the goal and your change in README.md. Success: `./my-player/player.bas` exists and differs from the starter. Fallback: none. Pick the defaults above and continue. Next: Step 4. ## Step 4: Upload (≤15 min) Choose the policy name: the name the human gave, otherwise `generals-io-player`. Always pass it with `--name`; without it the CLI invents a name from your account, not the one the human chose. SOFTMAX_CONFIG_DIR=.softmax uv run coworld upload-policy --file ./my-player/player.bas --name --title "<2-5 word version name>" --description "" Success: the output prints `Policy name: ` (the name you passed, unchanged) and a version. Print both back to the human and save them in README.md. The policy reference is `:v` for the version printed. Fallback: not signed in: the Step 1 sign-in text (a one-time code from the prompt that was not used yet comes first). Anything else: `HANDOFF: Upload failed. Output: `. Next: Step 5. ## Step 5: Request hosted games, then report (≤15 min) Write `xp-request.json`, replacing `` with the reference from Step 4: { "target": {"league_id": "league_f189ad01-2700-4fc8-ba89-f9de477099e4", "division_id": "div_ac86d8a0-7b43-41bb-b6e7-1867f7b47d08"}, "num_episodes": 4, "private": true, "roster": [ {"player": {"policy_ref": ""}, "slot": 0}, {"player": {"policy_ref": ""}, "slot": 1}, {"player": {"policy_ref": ""}, "slot": 2}, {"player": {"policy_ref": ""}, "slot": 3}, {"player": {"random": true}, "slot": 4}, {"player": {"random": true}, "slot": 5}, {"player": {"random": true}, "slot": 6}, {"player": {"random": true}, "slot": 7} ] } SOFTMAX_CONFIG_DIR=.softmax uv run coworld xp-request create xp-request.json --title "" --description "" Then poll at most 10 times, 60 s apart: `SOFTMAX_CONFIG_DIR=.softmax uv run coworld xp-request get --json`. Success: at least one finished episode. Read the game's scoring rules and the results from `xp-request get --json`. Report each policy's scores and finishing places when available; include wins, draws, and losses only when the game's rules define them. Never infer a win or draw from a score alone. Report game-specific metrics only when the game provides them. Say plainly when 4 games cannot tell anything apart, propose one next change, and stop. Fallback: nothing finished yet: `HANDOFF: Games are queued (rounds run about every 30 min). Check https://softmax.com/browse, then tell me "continue from Step 5".` League submission: only if the human asks in this session, and only after hosted results show improvement. ## If something goes wrong | You see | Do | |---|---| | Still running past the step's time budget | Stop, then HANDOFF with the command and its last output | | `Not authenticated` | HANDOFF with the sign-in text from Step 1 | | `docker info` fails (container games only) | HANDOFF with the Docker text from Step 3 | | Network or DNS errors | HANDOFF with the network text from Step 1 | | `uv` permission or cache error | Re-run with `UV_CACHE_DIR=.uv-cache` | | `exchange-code` fails with a permission error writing `~/.softmax` | The CLI is older than the release that supports `SOFTMAX_CONFIG_DIR`: run `UV_CACHE_DIR=.uv-cache uv lock --upgrade-package softmax-cli` (drop the cache prefix if unneeded), retry, else HANDOFF with the output | | Docs and CLI disagree | Trust `--help`; note it in README.md |