The hierarchy #
Princess decides one unit at a time. It picks a unit, scores every legal path for it, takes the best, and moves on to the next knowing nothing about what that one will do. Eight locally-best moves are not a plan, and the visible symptom is the conga line: a lance in single file, each machine having made a defensible choice.
SDS splits the thinking across the force structure MegaMek already keeps. This page is the vocabulary — what the layers are, what changes at each, and which of them are decisions rather than labels.
The layers #
graph TD
S[Scenario<br/><i>the rules of winning</i>] --> O[Objective<br/><i>what we are trying to do now</i>]
O --> C[Side<br/><i>the coordinator</i>]
C -->|Tactic| F1[Force]
C -->|Tactic| F2[Force]
C -->|Tactic| F3[Force]
F1 -->|TaskOverride, rare| U1[unit → Task]
F1 --> U2[unit → Task]
F2 --> U3[unit → Task]
F3 --> U4[unit → Task]
Scenario — the map, the forces, and the rule that decides a win. Fixed for the match.
Objective — what we are currently trying to do: win on battle value, or preserve what is left. It is the utility function, not a constraint list. Under one objective a withdrawal is terrible; under the other it is the plan. Changing it changes what "good" means everywhere below.
Force — a group of units with a commander. One side holds a handful of them.
Tactic — what one force is doing: Engage, Flank, Entrench and so on.
One per force, chosen at the start of a turn, changed mid-turn only by a forcing
event. A tactic biases movement, firing and physical scoring; it does not pick
hexes.
Task — what one unit actually does this turn: a hex, a facing, a volley. Every unit always has one. This is the enormous space, settled by ranking candidates on the feature basis. Task decisions are within-phase, which is a rule strong enough to have its own section below.
TaskOverride — a force constraining one of its units: you are the spotter, you carry that squad. Rare, and an exception by design. A constraint narrows the task set; it does not choose within it, so a unit told to be a taxi still has several hexes to pick from.
A Task decides within its phase and stores nothing #
Nothing is passed to a later phase and nothing is recorded as an intent. A decision may calculate about later phases — it usually must — but it may not write down a plan. Each phase re-scores the best options available from the state as it actually is when that phase arrives.
The reason is that the state moves underneath a stored plan. Between phases a unit loses an arm, is knocked down, or the thing it meant to shoot stops existing. A plan recorded in the movement phase and executed in the physical one is a claim about a board that no longer exists, and the code that notices would be a validation layer nobody asked for.
Melee is the worked example, and the case that forces the rule. Firing and physical attacks are different phases, and firing an arm weapon means that arm cannot punch — so the firing decision genuinely has to weigh holding the arm back. It does that with a virtual punch candidate: a pseudo-weapon in the firing menu carrying an estimate of what a punch would be worth, so the two compete on one axis and the trade is priced rather than assumed. And it writes down nothing. When the physical phase arrives it scores what is available then, with no memory of why the arm is free.
The subtlety is what that candidate prices. It is an option, not a plan — "what is a free arm worth" and not "what is the punch I am going to throw worth."
The contrast with a Tactic is what makes this a principle rather than a
detail. A tactic persists across rounds, so there is stored state, so a
tactic whose target dies needs a forced transition to revise it (see
plan/tactics.md). A task does not persist at all, so there is nothing to
revise and no validation to write. Same problem, opposite answers, and the
reason is the timescale table below: a tactic changes every few rounds, a task
changes every round for every unit. Something re-decided that often should not
be stateful.
The structure is flat, deliberately #
There is no recursive force tree. A side holds forces, a force holds units, and that is the whole depth.
Company-on-company is the practical ceiling for a played game — a command lance covering three others is sixteen units. Depth beyond that buys nothing and costs a great deal: a recursive design needs a general contract at every level, and a bug in it is a bug at all depths at once. Two fixed hops can be read as straight-line code.
Flattening also makes cross-attachment ordinary rather than special. An infantry company that belongs to the command lance but fights alongside the artillery is normal task organisation, and a strict tree cannot express it without edges layered on top of containment. With one coordinator it is just a relation.
Note that a Command Lance is not a command layer. It is a formation type with composition requirements that also fights. The commander is a unit inside it, not a level above it.
Timescales #
The layers are separated by how often they change, and that is a design constraint rather than an observation: what a layer costs should be inversely proportional to how often it changes.
| layer | changes |
|---|---|
| Scenario | never, barring a GM |
| Objective | perhaps once a match, and usually only when losing |
| Tactic | every few rounds; every round at most |
| Task | every round, for every unit |
So Tasks get cheap scoring over a huge space, Tactics can afford a transition model with hysteresis over a small one, and the Objective can afford real deliberation because it is decided once. Anything expensive at Task frequency is misplaced, and so is anything trivial at Objective frequency.
Roles and Formations are labels, not decisions #
Both are classifications derived from what a force brought, and neither should drive scoring.
A unit Role is MegaMek's own field — Brawler, Sniper, Missile Boat, Scout, Skirmisher, Striker, Juggernaut, Ambusher. The rules derive it from three questions: how fast is the unit, how tough is it, and at what range does it prefer to fight?
A Formation is the same idea for a force: Battle Lance, Fire Lance, Recon Lance, and their variants, each defined by composition requirements over weight class, movement, armour, damage at a range, and the roles present.
Both are lossy. A BGS-7S is labelled Missile Boat and carries MMLs that want a medium fight. A Dasher C is a Missile Boat whose LRMs have no minimum range and which wants to ferry armour and hunt backs. Formations misclassify the same way — a Hunter Lance qualifies at exactly 50% Ambushers while being mostly logistics.
The rule that follows:
Continuous capability drives decisions. Discrete labels are portrayal.
A unit's own range profile, speed and durability go into the feature basis. The word "Missile Boat" goes on the screen, so a person can read what the bot is doing. Formation likewise gives a force a name and a starting hint for its tactic, and stays out of the scorer — where a threshold at 50% would make a force at 49% behave discontinuously differently for no reason in the world.
That hint is built. FORMATIONS.md is the order the formations are asked in, what each one asks for, and the tactic each one opens on.
Commanders #
Each force has one, and in a campaign it is a specific pilot in a specific machine — which can be killed. That makes a commander a mortal object rather than a configuration value, and it is where a force's personality attaches.
Two consequences worth stating: killing the enemy commander becomes a tactic with
a modelled payoff rather than flavour, and protecting ours becomes a reason to
screen. MegaMek carries commander on the entity already; we do not yet read it.
The tactics #
A tactic is a weighting over the named feature basis — Weights::hand_authored
with one family re-priced — which is what makes a tactic fittable rather than
authored. crates/sds-core/src/tactic.rs holds the vectors and the argument for
every number in them, and TACTICS.md is the roster generated from
that same list: what each tactic is for, which features it re-prices, whether
anything can order it, and what the basis is still missing for the ones nothing
can. Neither is restated here, because a copy of a list the code owns is a copy
that goes stale without anything failing.
Engage is the default, by definition. It optimises for winning trades
against enemies globally; it is what the bot plays with no tactic layer at all;
and Engage::weights() is Weights::hand_authored() rather than merely
resembling it, which is asserted by a test. Every other tactic is defined as that
vector with one family of features re-priced.
Two things follow, and both are definitions rather than findings. Engage is the
nearest neighbour of most other tactics — a spoke can only land far from the hub
by moving a family that dominates the decision, so this is the expected property
of a hub and not a pattern anybody discovered. And a tactic that reads as
"Engage with one term moved" is not thereby suspect: that is what a spoke is.
The question a measurement can answer is whether each spoke earns its distance,
not whether the hub is real.
A tactic that cannot be expressed against today's basis says so rather than
scoring by a stand-in: it carries a written GAP naming what is missing, has no
Assignment variant so nothing can order one, and is left out of the agreement
matrix, because a stand-in's agreement is a fact about the stand-in. A tactic is
therefore either implemented or unimplemented; there is no third state and no
list of ideas kept elsewhere. See plan/scoring.md.
Targeting is a compile error, not a check. Screen covers one of ours and
Flank closes on an enemy, and those are separate traits rather than the same
Option<i32> with a comment beside it. Screen::against(EnemyId(12)) does not
fail at run time; it does not build. One Assignment enum wraps them all, so a
coordinator can still hold a heterogeneous list.
Some tactics imply a distribution of work inside a force rather than the same order to everybody — a lance cannot all flank; most of it holds attention while one unit takes the arc. A tactic that broadcasts one instruction to every unit cannot express a plan whose point is that units do different things.
Melee is deliberately not a tactic. Its shape arrives as the virtual physical
candidate described under A Task decides within its phase, priced inside the
firing menu and storing nothing — and Charge would collide with the name of an
actual physical attack, the same trap Skirmish fell into with the Skirmisher
Role.
Measuring whether a tactic is new #
sds tactics <corpus> re-scores every candidate menu in a recorded corpus under
each tactic and reports the share of decisions where each pair picked the same
candidate. It plays no matches: a decision log already carries every candidate
and the phi it was measured at, so "what would Flank have done here?" is a
question about a decision that has already happened. What it measured, drawn, is
TACTICS.md.
Read it the way sds labels is read — under 0.4 a new direction, over 0.8 mostly
a rename — with one difference: this is an agreement fraction over a menu 364
long at the median, not a correlation, so chance agreement is near zero rather
than near a half. And with the same standing warning, which the labels sweep
taught: a figure from one corpus is a property of that corpus until a second one
agrees.
What is built, and what is not #
Built: two-level planning, the barrier where a force waits for all its units, menu reconciliation, and node transports so a level can run on another machine.
Built, since: the tactic layer. Every force carries a Tactic, and that Tactic
selects the weights it scores its candidates with — a re-pricing of whatever
base the bot is playing with rather than a replacement of it, so a fitted
weights file survives a force changing tactic. A force is given one by its
Formation, proposes a new one each round over a basis that cannot read a hex,
and the coordinator harmonises the proposals. See
FORMATIONS.md, SITUATION.md and
sds_core::harmonise.
Not fitted. The tactic weights are hand-authored, and the one number that decides which tactic a force proposes is a single weight on "is this what my formation opens on". Everything else in the situational basis is measured, recorded and weighted at nought, waiting for a fit.
Still there, and deliberately: the stance — a weight over hand-scored
intents, chosen by summing per-unit appraisals whose arithmetic is hand-written
rather than fitted. It no longer decides anything. What it still does is
surfacing: surface::vectors_for("stance", …) picks which candidates are
carried up with a readable label, which is a reader's concern rather than a
decision. Two scoring systems is not the end state, but cutting the list down
for a reader is a different job from choosing, and the two want separating on
purpose rather than at once. See plan/scoring.md and plan/tactics.md.
Where a node runs is an address #
A node is local:unit, proc:python3 planner.py, or tcp:10.0.0.4:7000. Same
line protocol on all three, so moving a thinker is a config change:
SDS_LANCE_NODE=tcp:10.0.0.4:7000 sds-bot
That indirection is not decoration. A planner backed by a language model wants a key, a bigger machine and a much longer deadline than a unit thinker that runs a thousand times a second next to the state — and both have to hang off the same structure or the structure is a fiction.