# The bot protocol Newline-delimited JSON, one document per line, on the bot process's stdin and stdout. The host writes; the bot answers. stderr is the bot's own, passed through to the match log. The protocol is the product. Everything else in this repository — the JVM, the container, Princess — is scaffolding that can be replaced around it, and this document is the part that is meant to outlive them. ## Why a pipe MegaMek's own bot is a client: it holds a live, mutable `Game` and reaches into it. That is why Princess cannot be replayed, cannot be unit-tested, cannot be run outside a game, and cannot be written in anything but Java. A bot behind a pipe has none of those properties fused to it. The cost is a serialisation step per decision; on a 4v4 that is well under a millisecond against the tens of milliseconds Princess spends thinking, so it does not show up. ## No Princess, anywhere, ever `SdsClient` extends `BotClient`, not `Princess`, and no decision on an SDS seat is ever handed to Princess — not on a pass, not on a timeout, not on a crash. This is the rule the rest of the design bends around, and it is about debugging rather than purity. If a seat sometimes plays Princess's move, then no line in a match log tells you whose decision you are looking at, and every investigation starts by trying to work out whether the thing you are staring at is even yours. Holding the rule costs a worse-playing bot early on and buys a match log that means exactly one thing. The line between what may be borrowed and what may not: **MegaMek's rules yes, MegaMek's bot no.** `Board.isLegalDeployment` and `WeaponAttackAction.toHit` answer "what does the game allow" and every human player has them too. The ranking inside `BotClient.getStartingCoordsArray` is a tactical opinion, and is not called even though `SdsClient` inherits it. ## Documents ### `board` — host to bot, once per match Sent before the first observation. ```json {"type": "board", "width": 16, "height": 17, "hexes": [{"x": 3, "y": 4, "level": 1, "terrain": {"woods": 1}}]} ``` Sparse: a hex at level 0 with no terrain is omitted. Coordinates are MegaMek's zero-based `(x, y)`, not the one-based numbers printed on a map sheet. Terrain that changes mid-fight — fire, smoke, a collapsed building — is **not** tracked. A bot that cares must currently do without. `level` and `terrain` are everything movement needs from a hex: `crates/sds-core/src/pathfind.rs` derives MegaMek's per-hex movement cost from the terrain names and their levels, and its floor from `level` less the `water` depth. What movement cannot get from the board is a property of the *unit*, which is why `movementMode` and `maxElevationChange` are on the observation. ### `observation` — host to bot, before each decision ```json {"type": "observation", "seq": 7, "round": 3, "phase": "MOVEMENT", "playerId": 1, "team": 2, "actor": 12, "eligible": [12, 14, 15], "units": [{"id": 12, "name": "Griffin GRF-1N", "ownerId": 1, "team": 2, "friendly": true, "x": 8, "y": 14, "facing": 0, "secondaryFacing": 0, "twists": [0, 1, 5], "elevation": 0, "weight": 55.0, "bv": 1272, "bvOriginal": 1387, "walkMp": 5, "runMp": 8, "jumpMp": 5, "movementMode": "BIPED", "maxElevationChange": 2, "armor": 128, "armorMax": 144, "internal": 91, "internalMax": 91, "heat": 4, "heatCapacity": 10, "heatBuildup": 2, "explodableAmmo": true, "prone": false, "shutDown": false, "destroyed": false, "crippled": false, "done": false, "gunnery": 4, "piloting": 5, "role": "SNIPER", "forcePath": ["Alpha Company", "1st Lance"], "locations": [{"name": "CT", "armor": 20, "armorMax": 22, "rearArmor": 6, "rearArmorMax": 8, "internal": 18, "internalMax": 18}], "weapons": [{"id": 3, "name": "LRM 10", "location": "RT", "heat": 4, "short": 7, "medium": 14, "long": 21, "rackSize": 10, "damagePerPacket": 1.0, "toHitModifier": -2, "fireModes": [{"name": "Single", "shots": 1}, {"name": "Ultra", "shots": 2}], "currentMode": "Single", "usable": true, "ammo": {"type": "LRM", "munition": "Standard", "damagePerShot": 1, "rackSize": 10, "shotsLeft": 12}}]}]} ``` `toHitModifier` is the weapon's own to-hit modifier, from `EquipmentType.getToHitModifier`: -2 for a pulse laser, +1 for a Clan heavy laser, absent for everything else. It is not a range term and not gunnery. The `shots` list already has it — those numbers are `WeaponAttackAction.toHit` — so the reader that needs it is a bot projecting a to-hit of its own, in the movement phase. `fireModes` is the rates of fire the mount offers, and appears only when one of them throws more than one shot: `Single`/`Ultra` for an Ultra autocannon, `Single` through `6-shot` for a Rotary AC/5. `shots` is `Mounted.getNumShots(type, mode, false)`. These are not the ammunition alternatives `modes` carries — one declaration, more projectiles, rather than a different projectile — and `currentMode` says which the mount is set to now. `locations` is armour and structure where damage actually lands. The totals above it cannot answer a fire decision — a shot hits one location, so "does this allocation open the centre torso" needs the centre torso. `rearArmor` and `rearArmorMax` appear only on the locations the rules give rear armour: centre and side torsos. Every number is a count; MegaMek's negative armour states (`ARMOR_NA`, `ARMOR_DOOMED`, `ARMOR_DESTROYED`, which are -1, -2 and -3) are converted to zero, and a location that is gone carries `"destroyed": true` instead. A location the unit does not have is left out. A weapon's `location` is where it is bolted on, by MegaMek's own abbreviation — the same names `locations` uses. The mount is what decides which hexes a weapon bears on: `Mek.getWeaponArc` reads it, and `Mek.isSecondaryArcWeapon` is false for the legs, so a leg weapon fires along the unit's facing and a torso twist does not move it while an arm or torso weapon follows the twist. `rearMounted` is sent only when true and beats the location outright: the weapon fires into the rear arc wherever it sits. Two readings on the bot's side, and they are not the same. A weapon with **no** `location` — an observation recorded before the field existed — is read as a centre-torso weapon, which fires forward and follows the twist like every mount but the arms and the legs. A `location` the bot does **not** model is read as firing forward and never following the twist: MegaMek has locations a biped does not, and the one that exists today is a tripod's centre leg (`CL`), which is a leg. Reading it as a centre torso would hand a leg the torso twist. `ammo` is what is in the tube, and is absent for an energy weapon and for an empty rack. A launcher's damage is not a property of the launcher: an SRM-6 throws two points a missile with standard ammo and none with Inferno, which deals heat instead. `damagePerShot` is per missile for a cluster rack. The three heat fields are not interchangeable and one of them has been misread before. `heat` is the scale as it stands, carried from last turn. `heatBuildup` is `Entity.heatBuildup`: what this turn has already banked — running, jumping, an engine crit — and not yet applied. `heatCapacity` is `Entity.getHeatCapacity()`, which is **dissipation**, what the sinks shed in the heat phase. It is *not* a ceiling: the shutdown, to-hit and ammunition thresholds are fixed points on the scale (14, 18, 22, 26, 30) and have nothing to do with it. The bot's `Unit::end_of_turn_heat` is the only thing that should combine them. `explodableAmmo` is whether the unit carries ammunition a heat roll could set off. It is MegaMek's own set, taken from `TWGameManager.explodeAmmoFromHeat`: an undestroyed ammo bin that reports `EquipmentType.isExplosive`, still has hittable shots, and is not a coolant pod, vehicle or heavy flamer ammo, or a bin loaded with coolant munitions. False for an energy boat, which is a real answer and not a missing one. `role` is MegaMek's own role for the design and `forcePath` is the unit's place in MegaMek's force tree, outermost first. Both are data the game already keeps, and both are hints rather than instructions — see [HIERARCHY.md](HIERARCHY.md) for why a role must never bind a decision. `eligible` is every unit of ours that may take this turn, ids only; each is described in `units` like any other. Which of a side's units acts next is a real decision MegaMek's turn structure hands to the player — turn order is initiative-based and alternates between sides, so the choice is "which of ours goes now" rather than free ordering of the round. It is present in the movement and firing phases and absent in deployment, where MegaMek's own ordering decides and there is no choice to offer. The set is `Game.getFirstEntityNum(GameTurn)` widened from "the first" to "all of them" — MegaMek's own enumeration, filtered by nothing but `GameTurn.isValidEntity` — so `eligible[0]` is the host's own pick by construction. `isValidEntity` is the whole rule and it is what the game options act through: it checks ownership against the turn's player, `isSelectableThisTurn()`, and, in movement, `inf_move_later` and `protos_move_later`. A `SpecificEntityTurn` overrides it to admit exactly one unit, which is the case where there is genuinely nothing to choose. `actor` is the unit the host will act on **if the bot does not choose one**. It is a default, not a fact: a reply naming a unit from `eligible` overrides it. Name anything else and the host refuses it, falls back to `actor`, and counts a `substituted` — a silently substituted unit would make the log say one machine moved while another did. `facing` is where the hull points and `secondaryFacing` is where the torso points. They differ whenever a unit has twisted, and the arc every weapon but a leg mount bears through is measured off the **secondary** facing. `twists` is every secondary facing the unit could still declare, ascending: MegaMek's own answer, from `Entity.canChangeSecondaryFacing` and `Entity.isValidSecondaryFacing`, which already fold in prone, bracing, having twisted once already, the `no_twist` and `ext_twist` quirks, and a chassis with no torso to turn. A unit that cannot twist carries the one facing it is at. In the firing phase `shots` is priced for **every** unit in `eligible`, and each shot names the `shooter` it belongs to. A weapon id is meaningless without the unit it is mounted on: equipment numbers are per unit and mixed, so number 7 is an autocannon on one Mek and a ton of ammo on the next. A shot with no `shooter` is an observation older than this field, where every shot was the actor's. `units` is every unit with a position, both sides: there is no fog of war, because MegaMek's own client has none and a bot that pretended otherwise would be playing a different game than the human across the table. Undeployed and removed units are omitted rather than sent with a null position. ### `action` — bot to host ```json {"type": "action", "seq": 7, "kind": "move", "unit": 14, "steps": ["TURN_LEFT", "FORWARDS", "FORWARDS"]} {"type": "action", "seq": 7, "kind": "fire", "attacks": [{"weapon": 3, "target": 9, "mode": "Ultra"}], "secondaryFacing": 1} {"type": "action", "seq": 7, "kind": "deploy", "x": 4, "y": 16, "facing": 0} {"type": "action", "seq": 7, "kind": "pass"} ``` `unit` names which of ours the action is for, and must be one of the observation's `eligible` ids. Leave it out and the host uses `actor`. An attack may carry `mode`, one of the mount's `fireModes` names. Leave it out and the mount is left where it is. A mode the mount does not offer is refused, counted as `illegal` and printed, and costs that one shot — the same treatment a torso twist the rules will not take gets, except that a bad twist costs the whole volley and a bad mode costs one weapon. An action may also carry `turn`, the turn-order record: which units the force planned for, which it picked and at what value, and the replan that produced those orders. The host copies it onto the decision log line. Identities and values only — a feature vector per eligible unit per turn would multiply a log that is already megabytes for a 2v2. ```json {"turn": {"phase": "movement", "eligible": [12, 14], "chose": 14, "value": 0.81, "differed": true, "considered": [{"unit": 14, "label": "close on 9", "value": 0.81}, {"unit": 12, "label": "hold the ridge", "value": 0.10}], "replan": {"settled": [9], "reason": "enemy 9 settled"}, "replans_this_round": 2}} ``` An action may also carry `rationale`, any JSON the bot likes. The host does not read it and writes it to the decision log. It may also carry `training`, an object whose keys the host copies onto the decision log line — see below. A hierarchy whose reasoning cannot be read back is a hierarchy nobody can debug, which is the original complaint against Princess; the SDS bot puts each force's stance, what changed its mind, and every proposal it rejected in there. - `seq` **must** echo the observation's. A mismatched reply is discarded, because a stale answer is a move for a unit that has already moved. - `kind: "move"` — `steps` are `megamek.common.enums.MoveStepType` names. An empty list is legal and means "stand still", which is not the same as passing. - `kind: "pass"` — take the harness's default for this decision. The defaults are **ours**, listed below, and are deliberately inert rather than competent: a bot that passes on everything stands in its deployment hex and is shot to pieces. Passing is how a bot that only handles movement declines to have an opinion about firing, and the cost of that is visible on the scoreboard rather than hidden behind a second bot playing well on its behalf. Steps rather than a destination, on purpose. A destination would need a pathfinder on the host side, and *how* a unit gets somewhere — which facing it ends on, what it walks through, whether it jumps — is most of what a move is. Handing that to the harness would mean the harness is playing. Every one of these is counted and reported in the match result. None of them ends the match: a bot that fails half its decisions still finishes, and the counters are what say so. ### The decision log Each SDS seat appends one JSON line per decision to `/-.decisions.jsonl`, next to the match result: ```json {"seat": "North", "seq": 7, "round": 3, "phase": "MOVEMENT", "actor": 12, "eligible": [12, 14], "actedOn": 14, "turn": { ... }, "outcome": "answered", "taken": "[MovePath] Length: 3; To: Coords (7, 2)", "action": {"kind": "move", "steps": ["FORWARDS"]}, "rationale": [ ... ], "positions": [{"id": 12, "name": "Griffin GRF-1N", "friendly": true, "x": 7, "y": 2, "facing": 3, "done": false, "destroyed": false}], "unit": 12, "candidates": [ {"label": "close on Griffin GRF-1N", "phi": {"expected_damage": 0.41, "p_kill": 0.02}, "value": 0.63}, {"label": "hold position", "phi": {"expected_damage": 0.10, "p_kill": 0.00}, "value": 0.21}], "chosen": 0, "learnable": ["expected_damage", "p_kill"], "local": ["damage_lead"]} ``` `outcome` is the same word the counters below are named after, and `taken` is what the host actually did — which is not the same claim as `action`, because a path the bot sent and the host rejected leaves the unit standing still. The gap between those two is the entire content of `illegal` and `defaulted`. `positions` is where every unit on the board stood when the question was asked, both sides, from the same observation the bot was given. It is the host's record rather than anything the bot believed, and it is what lets a reader say where an enemy actually went next to where the bot thought it might go. A unit with no position - undeployed, or off the board - is not in the list. `eligible` is the set the host offered and `actedOn` is the unit that actually acted; `actor` is still what the host would have picked on its own. The three together are what tell "we sent the wrong unit" apart from "no other unit could have gone". `turn` is the bot's own record of the choice, copied through untouched. The fields from `unit` onward are the **training row**, and they are present only when the bot sent a `training` object on its reply. They are what a fit reads: - `candidates` — every option the bot measured, not only the one it took. A fit needs the alternatives; a record of the winner alone has nothing to learn from, because there is nothing the winner beat. - `phi` — feature name to normalised value, per candidate. Names are exactly the ones in `sds_core::features`. A feature that was not measured is omitted rather than written as null. - `value` — the dot product the bot ranked with. Nothing is multiplied onto it, so a reader can recompute it from `phi` and the weights and check the corpus against what was actually played. - `chosen` — index into `candidates`. - `learnable` and `local` — the feature names by normalisation class. A `local` feature is min-maxed across this decision's own candidates, so it ranks a menu and means nothing across two decisions; anything fitting weights must drop those columns. Naming them here is how that guarantee reaches a reader that does not have Rust's type system. The host copies the keys of `training` onto the line rather than nesting them, so one decision is one flat document. `round` and `phase` are already on the line and are not repeated inside the row. Written from `SdsClient` and flushed per line, so the file can be read while the match is still being played. `sds view ` renders one as HTML, and `sds watch ` serves it on loopback while it is still being appended to, tailing by byte offset. The directory is the one named by `--log-dir`, defaulting to wherever `--out` writes the result. The bot process is told about it, and about which match it is playing, through the environment: | variable | set by | meaning | |---|---|---| | `SDS_LOG_DIR` | `SdsClient` | where this run's logs go | | `SDS_MATCH_TAG` | `SdsClient` | the match tag, so a file the bot writes itself can be joined to the result | | `SDS_SEAT` | `SdsClient` | this seat's name | | `SDS_SEED` | the harness | the seed a stochastic bot must be repeatable from | | `SDS_IMITATE` | the harness, on `bench --imitate` | write the imitation corpus below | ### The imitation corpus `/-.imitation.jsonl`, written by the **bot** rather than by the host, and only when `SDS_IMITATE` is set. One line per opposing move the bot could reconstruct from two consecutive observations of one movement phase: ```json {"round": 3, "phase": "MOVEMENT", "owner": 1, "observer": "North", "unit": 7, "candidates": [ {"label": "close on Griffin GRF-1N", "phi": { ... }, "value": 0.63}, {"label": "observed move", "phi": { ... }, "value": 0.11}], "chosen": 1, "learnable": [ ... ], "local": [ ... ], "policy": "observed"} ``` The training row is the same schema, so a fit reads it with no new parsing. Two fields are not on a decision-log line: - `owner` — the id of the player whose move this was. The observation carries player ids and no names; the match result carries both, and `sds train` resolves the one to the other so the row is labelled with **that** player's outcome. `observer` is the seat that watched, and is not the author. - `policy` — `argmax` on the bot's own rows, `observed` here. On an `observed` row `chosen` is somebody else's move and `value` is what *our* weights thought of it, so `chosen` is deliberately not the argmax. Nothing downstream can tell a corpus check from the point of the row without being told. Separate file and separate flag on both sides (`bench --imitate`, `train --imitation`): what it teaches is another bot's opinion, faults included. `docs/PRINCESS.md` and `plan/training.md` say which faults. ### The defaults | decision | default | |---|---| | movement | stand still — an empty path | | firing | declare no attacks | | deployment | the first legal hex, facing the middle of the board | | physical attacks | none, always | | artillery, morale, prephase | none, always | Physical attacks are worth a note: MegaMek's `PhysicalCalculator` would answer them well and does not live in the princess package, so using it would even be defensible. It is still somebody else's tactics showing up in our unit's turn, so the answer is none until the protocol carries physicals and a bot chooses them. Every one of these is counted and reported in the match result. None of them ends the match: a bot that fails half its decisions still finishes, and the counters are what say so. | what | host does | counter | |---|---|---| | replies `pass` | takes the default | `defaulted` | | sends an illegal path | takes the default | `illegal` | | sends an unknown step name | takes the default | `illegal` | | names an unknown weapon or a dead target | drops the whole declaration | `illegal` | | asks to deploy outside the legal hexes | uses the first legal hex | `illegal` | | answers the wrong `seq` | discards, takes the default | `defaulted` | | takes longer than `--timeout-ms` | takes the default, keeps the bot | `defaulted` | | names a unit that cannot act this turn | acts on `actor` instead | `substituted` | | closes stdout, or crashes | defaults for the rest of the match | `disabled` | `substituted` is not `defaulted`: the bot did answer, and the answer was refused rather than missing. Three more counters ride beside it, per phase and in total: `eligible` is the eligible-set sizes summed over decisions, so `eligible / decisions` is the mean size of the set the bot chose from and a mean of one means there was nothing to choose; `chose` is how often the bot named a unit the game accepted; and `differed` is how often that was not the host's own first-eligible pick, which is the number that says the choice is being used rather than merely offered. Read `answered` against `decisions` before reading any win rate. A bot that answered 10% of its decisions and "won" did not play the match — the defaults did, and they are inert. ## Phases the bot may answer Deployment, movement and firing. Physical attacks, artillery and morale are declined by the harness and are not yet on the wire. Which unit moves next is **not** the bot's yet: the harness moves the first unit that has not moved. Move order is a real tactical decision and should end up with the bot; it is a stated gap rather than an oversight. ## Phase-specific fields `observation` carries extra fields depending on the phase. - **`FIRING`** adds `shots`: every shot the rules permit, as `{"weapon", "target", "secondaryFacing", "toHit", "range", "damage", "heat"}`. Shots that are impossible or automatic failures are left out. The list is priced once per legal torso twist, and `secondaryFacing` says which one a shot belongs to. **They are alternatives.** A twist is a unit-level choice — every secondary-arc weapon fires along the facing that is declared — so one group is taken and the shots in the others do not exist. A reader that pools them offers the same tube several times. A shot with no `secondaryFacing` is an observation older than this field, where everything was priced at the facing the unit was already at. A `fire` action may carry `secondaryFacing`: the torso facing to declare before the guns, sent as MegaMek's own `TorsoTwistAction`. It is optional and absent means "leave the torso alone". It is a request, not an instruction — the host puts it through `canChangeSecondaryFacing` and `isValidSecondaryFacing` and, if the rules refuse it, counts an `illegal`, prints it, and holds fire rather than firing a volley priced at a facing the unit will not be standing at. Handing a bot the to-hit number is not doing its thinking. Every human player has that number on screen before choosing, and a bot made to reimplement range, movement, terrain, heat and actuator damage would be measured on how well it copied a rulebook rather than on how it fights. *Which* shots to take, and whether to take any, is untouched. - **`DEPLOYMENT`** adds `deployHexes` and `deployUnit`. `deployHexes` is the legal hexes, in board order, **unranked**, each `{x, y, elevation}`. MegaMek's bot sorts these by elevation and expected damage before choosing; that sort is a tactical opinion and does not belong in a document whose job is to describe the world. The elevation is not a ranking but half of the answer: a hex is legal *at an elevation*, a Mek wading in depth-1 water stands at -1, and a unit put down at an elevation the rules refuse cannot move for the rest of the match. `deployUnit` is the machine being put down, described like any other unit. It is not in `units` and cannot be - that list is everything with a position - so its `x` and `y` are `-1`: a machine that has not been put down is not standing anywhere. Everything else about it is true. ## The route not taken A bot could skip the JVM entirely and speak MegaMek's own protocol. That protocol is Java native serialisation (`NativeSerializationMarshaller`), and Rust can already *read* it — [`jaded`](https://crates.io/crates/jaded) and [`java-serialization`](https://lib.rs/crates/java-serialization) both parse the stream, and MegaMek is unusually amenable: two custom `writeObject` implementations in the whole tree, and `MovePath` marks its `game` and `entity` fields `transient`, so an outbound move is a `Vector` and some scalars rather than the object graph. Nothing writes that format from Rust, so it would be a bespoke encoder pinned to MegaMek's `serialVersionUID`s and field layouts. `Entity` alone is 370 fields. The adapter's coupling is to MegaMek's *API*, which breaks at compile time; an encoder's coupling is to its *wire format*, which breaks silently at runtime and looks like a bot playing badly. Worth revisiting if the JVM ever becomes the bottleneck. The documents above do not change if it does — that is the point of writing them down separately from the thing that currently produces them.