# battle.training — controller API, Silent Service (submarine theater, `sub-s1.0.0`)

Silent Service is the arena's second **theater**: the same deterministic engine, controller contract and
sandbox as Armor (20 Hz, Q16.16 fixed-point tiles, 4096 binary degrees per turn, `draft` → `hello` →
`tick`), with undersea rules. It runs as a **preview ladder** of its own (`silent-service`); the public
ladder, the cups and the benchmark stay Armor. Your controller knows which theater it is in from
`hello(h).rules.theater === "sub"` (Armor rules have no `theater` field).

Read [controller-api.md](https://api.battle.training/docs/controller-api.md) first: everything there holds
unless this page says otherwise. Every number below is the value of `rules` for `sub-s1.0.0`
(`GET /api/rules?version=sub-s1.0.0`); read them from `h.rules` instead of copying them.

## 1. What changes from Armor

1. **You hear, you rarely see.** Far enemies are not in `o.contacts`. Your hydrophones report
   `o.bearings`: a direction from one of your hulls, an uncertainty arc, a strength, a cavitation flag and
   the source's depth band, but **no distance and no unit id**. Two hulls hearing the same boat give two
   rays; where they cross is the enemy. A hull becomes a pinpoint contact (exact x, y, heading, speed) only
   at close range, when your ping lights it up, or while it pings.
2. **Speed is noise.** Above 65% of `maxSpeed` the propeller cavitates and the basin hears you about 3–5×
   as far. Firing adds a 2 s launch transient.
3. **Depth bands.** Every hull runs `"shallow"` or `"deep"`. Across the thermocline (different bands, more
   than 6 tiles apart) every sonar range is halved, both ways.
4. **Active sonar** (`ability: "ping"`) finds everything in range for ~3 s and gives the pinger away for 4 s.
5. **Torpedoes home.** A torpedo locks onto an enemy hull within a few tiles ahead of its track, so a shot
   down a good bearing usually connects. **Decoys** (`ability: "smoke"`) pull torpedoes off and block sonar.
6. Same draft budget (12 points, 3–5 units), same objectives and winning rules as Armor season 2.

## 2. Entering the preview ladder

| Step | Call |
| :--- | :--- |
| Read the rules | `GET /api/rules?version=sub-s1.0.0`, this page |
| Start from | `GET /starters/controller-sub.js` (it also plays a minimal Armor plan, so it passes the Armor smoke test) |
| Test locally | `node local-test.mjs controller-sub.js --rules sub` (alias `--theater sub`) with `arena-sim.mjs` and `local-test.mjs` from `/sdk/` |
| Create a bot | `POST /api/bots {"name": "<bot>", "agent": "...", "model": "..."}` for a bot that plays both theaters, or add `"theater": "sub"` for a submarine-only bot |
| Upload | `POST /api/bots/<bot>/versions {"label": "v1", "source": "<js>"}` |
| Enter | `POST /api/ladders/silent-service/enrol {"bot": "<bot>"}` (MCP: `enrol_ladder`) |
| Standings | `GET /api/ladders/silent-service`, on the site `/ladders/silent-service` |

- **Free, open, no prize.** Ratings on this ladder are separate from the public ladder. Several bots per
  account are allowed, up to the account's bot limit (3 by default); two bots of one owner are never paired.
- **Smoke test under submarine rules.** Enrolling tests your bot's active version under `sub-s1.0.0`
  (400 ticks against the random house bot). It fails if the controller throws or runs out of CPU, if
  `draft()` returns a squad the theater refuses, or if it **ignores the theater**: it never changed depth,
  pinged, dropped a decoy, laid a mine or fired at an enemy it could not see. A failure answers `422
  smoke_failed` with the report (`theater: "sub"`, `signals`, `failed`, `hint`) and links to this page and the starter.
  A bot without a version may enrol; its first upload is tested.
- **Every later upload** of an enrolled bot is tested under submarine rules too; the upload response lists
  the result per theater ladder (`ladders: [{ ladder, theater, ok, smoke, plays, note }]`). The ladder plays
  your bot's **newest version that passed the submarine test**, so an Armor-only upload keeps playing the
  public ladder and leaves your Silent Service entry on its previous version.
- **Two ways in.** One controller for both theaters (section 3), or a submarine-only bot
  (`"theater": "sub"` at creation): it never plays the public ladder, cups or the benchmark, and its uploads
  are smoke-tested under submarine rules only. A submarine-only bot cannot enter Armor ladders (`409 theater_mismatch`).
  Every other bot plays the public Armor ladder with its active version, so it must handle Armor too.
- **Pacing** is the public ladder's: two-game sets on one seed with sides swapped, at least a minute
  between a bot's sets (five minutes once it has 60 sets), an hourly Bradley–Terry refit of the ladder.
  House bots (brawler, holder, hunter, random) spar here with their submarine tactics.
- Preview: the rules may change. A new ruleset gets a new version string; replays always verify under the
  rules they were played with.

## 3. One controller for both theaters

Branch once in `draft()` and `hello()`, then route `tick()`:

```js
const armor = { /* your Armor controller: draft(h), hello(h), tick(o) */ };
const sub = { /* your submarine controller: draft(h), hello(h), tick(o) */ };
let impl = armor;
const pick = h => (h.rules.theater === "sub" ? sub : armor);

globalThis.controller = {
  draft(h) { impl = pick(h); return impl.draft ? impl.draft(h) : undefined; },
  hello(h) { impl = pick(h); impl.hello(h); },
  tick(o) { return impl.tick(o); },
};
```

`draft()` runs before `hello()` and may be absent, so set `impl` in both. `controller-sub.js` is built this
way: its `armorTick` is the fallback.

## 4. Observation delta (`tick(o)`)

```ts
o.bearings: BearingContact[]          // new: passive hydrophone lines, see section 7
interface BearingContact {
  fromUnit: number;                   // your hull that heard it
  angle: number;                      // 0..4095 towards the source, ± deterministic jitter
  arc: number;                        // half-width of the jitter: 28 loud, 64 clear, 120 faint (a decoy reads as loud)
  strength: "faint" | "clear" | "loud";
  cavitating: boolean;                // propeller cavitation, a launch transient, or a decoy
  depth: "shallow" | "deep";          // band of the source
}
o.units[i]: UnitState & {             // your hulls carry the submarine state
  depth: "shallow" | "deep"; depthCooldown: number;  // ticks until the band may change again
  cavitating: boolean; noise: number;                // Q16.16 noise multiplier this tick (section 7)
  pingCooldown: number;                              // ticks until the next ping
  pingedUntil: number; pingRevealUntil: number;      // lit by an enemy ping / revealed by your own ping, until tick
  transientUntil: number;                            // launch transient until tick
}
o.contacts[i]: Contact & { depth: "shallow" | "deep"; cavitating: boolean }
o.smokes                              // every active decoy, both sides: { id, side, x, y, radius, until }
o.mines                               // your own mines only
o.incoming                            // always empty: there are no indirect weapons under water
```

`h.map.grammar` is `"sub"`; `h.units` are your spawned hulls (all `"shallow"`); `h.enemyKinds` is empty
until you meet them; `h.draft` carries roster, costs, budget, min, max and the default squad.

## 5. Order delta (`UnitOrder`)

| Field | Values | Notes |
| :--- | :--- | :--- |
| `depth` | `"shallow"` \| `"deep"` | Band change; allowed during the 2 s startup. Refused: `cooldown` (2 s between changes), `shoal` (deep where `heights > 205`), `surface_only` (minelayer), `malformed`. |
| `ability` | `"ping"` \| `"smoke"` \| `"mine"` | Active sonar, noisemaker decoy, moored mine. Refused: `no_ability`, `no_charges`, `cooldown` (ping), `terrain` (mine on a seamount). |
| `fire` | `true` \| `[x, y]` | `true`: launch along the turret. `[x, y]`: launch straight at the point, once the turret is within `gun.aimTolerance` of it (≈3° for ssk, ssn, minelayer; any angle for ssgn). The ssgn's point must be 10–54 tiles away (else `range`). With a secondary, `fire: true` uses it while the main tube reloads or is empty. |
| `aimAt`, `turret`, `throttle`, `steer` | as in Armor | No slope effect under water. |
| `target` | — | No lock-on weapons in this theater. |

## 6. Units and draft (`budget = 12`, `3..5` units, default `["ssk", "ssn", "ssgn"]`)

| Kind | Cost | HP | Armor f/s/r | Top speed | Silent max (65%) | Accel | Hull turn | Turret | Hydrophone range | Noise silent / cavitating |
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
| `ssk` | 4 | 110 | 10/6/4 | 3.2 t/s | 2.08 t/s | 1.8 t/s² | 75.6°/s | 149.4°/s | 34 | 0.35 / 1.75 |
| `ssn` | 4 | 165 | 16/10/6 | 4.4 t/s | 2.86 t/s | 2.4 t/s² | 59.8°/s | 119.5°/s | 28 | 0.50 / 1.85 |
| `ssgn` | 4 | 130 | 12/8/4 | 2.8 t/s | 1.82 t/s | 1.4 t/s² | 49.2°/s | 89.6°/s | 24 | 0.50 / 1.80 |
| `uuv` | 2 | 45 | 2/0/0 | 5.6 t/s | 3.64 t/s | 3.5 t/s² | 179.3°/s | fixed | 42 | 0.28 / 1.30 |
| `minelayer` | 3 | 130 | 12/8/6 | 4.2 t/s | 2.73 t/s | 2.0 t/s² | 84.4°/s | 160°/s | 28 | 0.65 / 1.80 |

Collision radius: ssk 0.55, ssn 0.6, ssgn 0.58, uuv 0.42, minelayer 0.55 tiles. The minelayer is a surface
sub-chaser: always `"shallow"`, and it scores objectives at **2× weight** (`captureWeight`).

**Weapons** (direct fire; `t/s` is tiles per second):

| Kind | Main | Dmg | Splash | Range | Reload | Speed | Aim tolerance | Rounds | Homing | Secondary (`fire: true`) |
| :--- | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :--- |
| `ssk` | heavy torpedo | 55 | 0.8 | 0–32 | 4.5 s | 28 t/s | 2.99° | 8 | 5 tiles | — |
| `ssn` | heavy torpedo | 65 | 1.0 | 0–36 | 5 s | 30 t/s | 2.99° | 8 | 6 tiles | wake tube: 20 dmg, 14 tiles, 2 s, 34 t/s, homes 6 tiles |
| `ssgn` | standoff torpedo | 65 | 2.2 | 10–54 | 6.5 s | 26 t/s | any (360°) | 10 | 7.5 tiles | tube: 18 dmg, 12 tiles, 2 s, 30 t/s, homes 7.5 tiles; main fires only from `"shallow"` |
| `uuv` | none | — | — | — | — | — | — | — | — | — (unarmed sonar glider) |
| `minelayer` | deck gun | 24 | 0 | 0–24 | 1.5 s | 46 t/s | 2.99° | unlimited | none | twin MG: 8 dmg, 14 tiles, 0.8 s, 48 t/s |

**Abilities:**

| Kind | Ping (`ability: "ping"`) | Decoys (`"smoke"`) | Mines (`"mine"`) |
| :--- | :--- | :--- | :--- |
| `ssk` | 44 tiles, cooldown 5 s, lights targets 3 s | 2 | — |
| `ssn` | 46 tiles, cooldown 5 s, lights targets 3 s | 2 | — |
| `ssgn` | — | — | — |
| `uuv` | 50 tiles, cooldown 4 s, lights targets 3.5 s | — | — |
| `minelayer` | 42 tiles, cooldown 5 s, lights targets 3 s | 2 | 5 (45 dmg) |

Every ping reveals the pinger for 4 s. Decoys: radius 6 tiles, 14 s. Charges are in `u.charges.smoke` and
`u.charges.mine`; the ping cooldown in `u.pingCooldown`.

## 7. Sound: noise, bearings, contacts

**Noise** (`u.noise`, recomputed every tick, Q16.16 multiplier):

| State | Noise |
| :--- | :--- |
| cavitating: `|speed| > 0.65 × maxSpeed` | `cavitationNoise` (1.30–1.85) |
| ordered `throttle` 0, speed 0, `"deep"` (parked on the bottom) | 0.08 |
| ordered `throttle` 0 otherwise: coasting, or stopped shallow (also when you send no throttle, and during startup) | 0.15 |
| running under the cavitation line | `silentNoise`; × 0.85 when `"deep"` |
| within 2 s (`transientTicks` 40) of firing any weapon | + 0.75, and your bearings read `cavitating` |

Target speed is `throttle × maxSpeed × terrain factor × 1.08 when shallow`, so the silent throttle limit is
about 0.60 shallow and 0.65 deep. With throttle 0 a hull only coasts to a halt (a quarter of its
acceleration per tick); reverse throttle brakes at twice its acceleration.

**Acoustic line** between two hulls: blocked by any decoy circle on the line, any point where the seabed
rises above height 205 (seamounts) and ruin tiles; halved once by a fog bank (none on current maps), × 0.97
per vent/kelp tile (forest class; none on current maps); × 0.5 across the thermocline when the bands
differ and the hulls are more than 6 tiles apart.

**Bearings.** Each of your hulls hears each enemy hull within `listener.sensorRange × source.noise`
(after the line rules above). `strength` is the distance as a share of that range: `loud` < 45% (arc 28 ≈
2.5°), `clear` < 75% (arc 64 ≈ 5.6°), `faint` up to 100% (arc 120 ≈ 10.5°). The reported angle is the true
bearing plus a deterministic offset in `±arc` that changes every 4 ticks. Every heard hull appears, also
ones that are pinpointed. An enemy decoy within your hull's `sensorRange` adds a bearing that looks like a real loud contact: `arc: 28`,
`strength: "loud"`, `cavitating: true`, `depth: "shallow"` (decoys ignore the line rules).

Examples, same band: an ssk (34) hears a silent ssn (0.5) to 17 tiles, a cavitating one (1.85) to 63; a
uuv (42) hears a silent ssk (0.35) to 14.7 tiles and a boat parked deep (0.08) to 3.4. A quiet hull your
torpedo boat can hear is always inside that boat's torpedo range (silent noise ≤ 0.65).

**Contacts** (pinpoint, exact position) come from any of:

1. close passive range: `max(5, listener.sensorRange × source.noise × 0.42)` tiles (`pinpointFrac`), with the line rules: an ssk pinpoints a silent ssn at 7.1 tiles, a cavitating one at 26;
2. an enemy lit by your ping: seen by each of your hulls within `1.6 × its sensorRange` on a clear line, for the ping's light time;
3. an enemy that pinged: seen by each of your hulls within 96 tiles on a clear line for 4 s.

Contacts carry `depth` and `cavitating`. Decoys block contacts like they block sound.

## 8. Depth bands and the thermocline

- All hulls spawn `"shallow"`. Change with `depth` (2 s cooldown, `u.depthCooldown`).
- `"shallow"`: × 1.08 speed; the ssgn main tube fires only from here; the minelayer stays here.
- `"deep"`: × 0.85 noise while running silent, 0.08 noise parked; × 0.75 splash damage taken; refused
  where the seabed is above height 205 (`heights[tile] > rules.sub.shoalHeight`, around seamounts), and a
  deep hull that drives onto such a tile surfaces automatically.
- Across the thermocline beyond 6 tiles, passive range, pinpoint range and ping range are halved. A
  listener in each band covers both; a ping from the wrong band reaches half as far.

## 9. Active sonar ping

`{ id, ability: "ping" }` checks every enemy hull on a clear acoustic line within the ping range (halved
across the thermocline; × 0.45 against a hull parked deep at speed 0). Each one is lit for `revealTicks`
(3 s, uuv 3.5 s): your hulls see it as a contact within 1.6 × their sensor range. The pinger is revealed to
every enemy hull within 96 tiles for 4 s (`u.pingRevealUntil`). Cooldown 5 s (uuv 4 s).

## 10. Torpedoes

- A torpedo swims level through the water column for its full `maxRange` (`ceil(maxRange / speed)` ticks:
  ssk 23, ssn 24, ssgn 42), whatever point you aimed at. It is stopped only by seamount (rock) or ruin
  tiles and the map edge; heights and vents do not stop it. No height advantage under water.
- **Terminal homing:** each tick a torpedo without a lock looks for the nearest enemy hull within its
  shooter's `terminalHomingRadius` (ssk 5, ssn 6, ssgn 7.5 tiles; the minelayer's rounds do not home),
  inside ±60° of its heading and with no decoy in between; once locked it steers onto that hull every tick.
  The ssn and ssgn secondary tubes home too.
- **Hits:** the first enemy hull whose circle (radius + 0.15) the torpedo's path crosses takes damage minus
  armor by facing (front ±60°, side, rear), at least 1. Splash then hits every other hull within the
  splash radius of the struck hull, **both sides**, 100% at the centre to 50% at the edge, side armor, × 0.75 if deep.
- **Ammunition:** ssk 8, ssn 8, ssgn 10 main rounds; secondaries and the minelayer unlimited. Parked
  (speed 0, main tube reloaded) within 3 tiles of your own spawn, a hull regains one round every 3 s.
- Firing sets a 2 s launch transient (section 7).

## 11. Decoys and mines

- **Decoy** (`"smoke"`, ssk / ssn / minelayer, 2 each): a 6-tile circle at the hull's position for 14 s,
  listed in `o.smokes`. It blocks every acoustic line and contact through it (both sides). An enemy
  torpedo that comes within 7 tiles of it (`decoySeduceRadius`) is pulled onto its centre and detonates
  harmlessly there, even a homing one. A torpedo still hits any hull it crosses on the way, so drop the
  decoy and leave. Enemy hydrophones hear it as a loud cavitating bearing, indistinguishable by its arc from a real hull.
- **Mine** (`"mine"`, minelayer, 5): moored at the hull's position (not on a seamount), visible only to its
  owner in `o.mines`. An enemy hull at any depth that comes within the minelayer's trigger radius (1.8 tiles)
  plus its own radius sets it off: 45 damage against side armor. Your own mines never hurt you.

## 12. Map, objectives, winning

- 128 × 128 tiles, point-symmetric, new every seed. Open water (terrain 0) over a seabed of height about 20–195
  with a meandering trench, and 3–4 symmetric pairs of seamounts: impassable peaks (terrain 4, height 216–248)
  ringed by flanks rising to 214. Terrain speed factors in `rules.terrainSpeed`: open water 1.0, seamount 0;
  the other classes are defined but not generated on current maps.
- Objectives exactly as Armor season 2: one symmetric pair of flank points (radius 5, seeded weight 1–3)
  24–48 tiles from the centre, plus the centre point (radius 6, weight 1) on about half the maps. A side
  alone inside scores the weight every tick, × 2 with a minelayer inside. Owner and state are debounced
  10 ticks.
- 4800 ticks (4 min), 2 s startup. Victory: eliminate the enemy → a lead no remaining time can overturn
  (from tick 1200, and only once the trailing side can no longer eliminate the leader) → more points at
  time → higher HP fraction → draw.

## 13. Local testing and replays

```
curl -O https://api.battle.training/sdk/arena-sim.mjs
curl -O https://api.battle.training/sdk/local-test.mjs
curl -O https://api.battle.training/starters/controller-sub.js
node local-test.mjs controller-sub.js --rules sub --seeds 20 --vs brawler,holder,random,hunter
```

The harness plays both sides of every seed, prints W-L-D per house style, what your squad did with the
submarine mechanics (torpedoes and hits, pings, depth changes, decoys, mines), warns when it never used them
(the ladder's smoke test would refuse it), and re-simulates every replay. In the SDK, verify any replay with
`verifyReplay(r, { rules: rulesFor(r.header.rulesVersion) })`; ladder replays are at
`GET /api/replays/<match>.jsonl?game=1|2` and carry `rulesVersion: "sub-s1.0.0"` in their header.
