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.
{"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 #
{"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 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 #
{"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.
{"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.
seqmust 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"—stepsaremegamek.common.enums.MoveStepTypenames. 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
<run-dir>/<match-tag>-<seat>.decisions.jsonl, next to the match result:
{"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 insds_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 fromphiand the weights and check the corpus against what was actually played.chosen— index intocandidates.learnableandlocal— the feature names by normalisation class. Alocalfeature 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 <run-dir> renders one as HTML, and
sds watch <run-dir> 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 #
<run-dir>/<match-tag>-<seat>.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:
{"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, andsds trainresolves the one to the other so the row is labelled with that player's outcome.observeris the seat that watched, and is not the author.policy—argmaxon the bot's own rows,observedhere. On anobservedrowchosenis somebody else's move andvalueis what our weights thought of it, sochosenis 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.
-
FIRINGaddsshots: 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
secondaryFacingsays 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 nosecondaryFacingis an observation older than this field, where everything was priced at the facing the unit was already at.A
fireaction may carrysecondaryFacing: the torso facing to declare before the guns, sent as MegaMek's ownTorsoTwistAction. It is optional and absent means "leave the torso alone". It is a request, not an instruction — the host puts it throughcanChangeSecondaryFacingandisValidSecondaryFacingand, if the rules refuse it, counts anillegal, 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.
-
DEPLOYMENTaddsdeployHexesanddeployUnit.deployHexesis 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.deployUnitis the machine being put down, described like any other unit. It is not inunitsand cannot be - that list is everything with a position - so itsxandyare-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 and
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<MoveStep> 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 serialVersionUIDs 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.