# Shtemeri — a guide for players and their LLMs

This text is everything you need to write a fleet. Paste all of it into your LLM (or have the LLM fetch it with the `get_guide` tool) before you start.

## 1. What this is

Shtemeri is an arena where the fighting is done by the player's code, not by the player. You write **one C# class** that derives from `Shtemer`. The server creates one instance of it for each shtemer (robot) in your fleet and sends the fleet into the arena against other fleets. The last fleet with a shtemer still alive wins.

The code runs only on the server. You watch the match in a browser, from above, and you see everything. Afterwards your LLM gets the telemetry: only what your shtemers saw, did and wrote to the log. That is why you are a team: you see the whole arena, the LLM sees it “from the inside”. Tell it what you saw.

The cycle: write a fleet → submit it → run a test match → watch it → analyse the telemetry → fix → repeat. When you are happy with a version, activate it in a mode: Ladder (fleets of 4 shtemers) and/or SOLO (a fleet of 1 shtemer). In each mode at most one version of a fleet plays, and the rating is kept per mode; on the Ladder a version joins the ranked matches that run around the clock. If you would rather not start from nothing, every house fleet's page on the site has a “Start from this fleet” button that makes a fleet of your own with its code; My fleets offers it too while you have no fleet yet.

**Rules of ranked matches.**

- A ranked match has at most 2 fleets of the same owner.
- One account may have at most 5 active fleets at a time per mode (5 on the Ladder and 5 in SOLO). An activation above that is refused; the version you submitted stays saved, it just is not active (fleets that were already active stay active).
- Premieres overlap: a new ranked match starts about every 40 seconds, so several matches are on air at once. That holds while enough fleets are free: a fleet that plays a match whose result is not published yet does not enter a new one, and when a full match cannot be formed the server waits for fleets to free up.
- The result, the placements and the rating change are public, and applied, only when the match has finished airing.

**The cup.**

- Every night from 00:00 to 08:00 (Europe/Zagreb time) a cup is held. Every fleet that is active on the Ladder at midnight enters, house fleets too, with no minimum. Each plays the version it had at midnight: that version is frozen until the cup ends. The cup is held only while it is switched on at the server (`GET /api/features` reports it as `cup`); while it is off, there is no nightly cup.
- The cup has its own rating and its own leaderboard: every fleet starts at 1000, and the Ladder is not touched. Everything else is as on the Ladder: at most 2 fleets of the same owner in a match, and the result, the placements and the cup rating change are public only when the match has finished airing. The one difference: cup matches follow one another, with no overlap.
- While the cup runs, you cannot take a fleet out of the pool (`set_active_version` with `none` is refused). You can leave SOLO at night as well. You may activate a newer version: the Ladder plays the new one, and the cup plays the frozen one until 08:00.
- You can delete a fleet (the "Delete fleet" button on the fleet page, or `delete_fleet` with `confirm`: true): it leaves every mode, stops counting toward the limit on fleets, and its name is free again; its versions and played matches stay in the history under the old name. It cannot be deleted while it plays in a running cup.
- You read the results of the cup with `list_cups` (the cups, with the winner and the number of cups in a row won by the same version) and `get_cup` (the cup leaderboard: `which` is `current` or `last`, or `cup_id`). `list_matches` with `kind` = `cup` gives your cup matches, and `get_match`, `get_timeline`, `get_telemetry`, `get_telemetry_download` and `get_replay` work for them as they do for ranked ones: only once the match has finished airing, and telemetry only for your own fleets. The replay and telemetry of cup matches are kept for 14 days.
- In a test match an opponent can be a version from the cup: write `name@cup` in `opponents` (e.g. `Kamper@cup`). That is the version the fleet plays in the cup that is running, or in the last finished one when none is. The source of somebody else's version stays hidden.

**Modes.**

- A mode is where a version plays. There are two. **Ladder** (`ladder`) means the ranked matches that run around the clock, with fleets of 4 shtemers. **SOLO** (`solo`) has the same arena, the same rules and the same pace (a new match about every 40 seconds, premieres overlap, at most 2 fleets of the same owner in a match), but a fleet has a single shtemer: `me.FleetSize` is 1. Each mode has its own leaderboard and its own rating. The result, the placements and the rating change of a SOLO match are public only when the match has finished airing, as on the Ladder.
- SOLO is switched on at the server, like the cup (`GET /api/features` reports it as `solo`). While it is off, the server refuses `game_mode` with `solo` and gives a message, and everything else works as before. When SOLO is switched on for the first time, the fleets that are active on the Ladder at that moment enter SOLO too, each with a rating of 1000.
- Activation is per mode. A fleet can be active on the Ladder, in SOLO, in both or in neither. In each mode at most one version of a fleet plays, and different modes may have different versions. The limit of 5 active fleets per account holds for each mode separately. House fleets are active in both modes, always with their latest version, and do not count towards the limit. The cup is played only among the fleets active on the Ladder; it does not see SOLO activations.
- The rating is per version and per mode. A version that enters a mode for the first time starts at 1000 in that mode, so a new version that you activate in both modes starts at 1000 twice, once in each. Going back to an earlier version brings back its rating in that mode.
- You choose the mode with the optional argument `game_mode`. Without it the tools work as they always did, on the Ladder; the exception is `submit_fleet` (below).
  - `submit_fleet` takes `game_mode` as a list (`["ladder"]`, `["solo"]` or both): the modes in which to activate the version you submit. Without it the version goes to the modes in which the fleet is already active (SOLO counts only while it is on), and a new fleet (or a fleet that is active in neither mode) goes to both, to SOLO only if it is switched on. The answer says in which modes the version was activated and in which it was refused (e.g. because of the limit).
  - `set_active_version` takes `game_mode` (`ladder` or `solo`, default `ladder`): `none` takes the fleet out of that mode. The refusal to leave while a cup runs applies to the Ladder only.
  - `get_leaderboard` with `game_mode` = `solo` gives the SOLO leaderboard, and without it the Ladder.
  - `run_test_match` with `game_mode` = `solo` plays the test match by SOLO rules (a fleet of 1 shtemer); your fleet without `version` plays the version that is active in SOLO, or the newest one if none is. The opponents are looked up in SOLO: `name` (e.g. `Kamper`) gives the version that fleet is playing in SOLO right now, and your own fleet in `opponents` plays the version active in SOLO, or the newest one if none is. `name@solo` asks for that without `game_mode`, but the fleet (yours too) has to be in SOLO. `list_opponents` (the `solo` columns) shows who is in SOLO, with which version, rating and matches. Without `game_mode` a bare `name` is the version from the Ladder. The source of somebody else's version stays hidden.
  - `list_matches` with `kind` = `solo` gives your SOLO matches, and `get_match`, `get_timeline`, `get_telemetry`, `get_telemetry_download` and `get_replay` work for them as they do for ranked ones: only once the match has finished airing, and telemetry only for your own fleets. The telemetry header carries `fleetSize: 1`. A test match played by the SOLO rules is labelled "test, SOLO rules" in `list_matches`, `get_match`, `get_replay` and `get_telemetry_download`.
  - `list_fleets` shows the version active in SOLO next to the version active on the Ladder, and for every version that has SOLO data its SOLO rating, mu, sigma, matches and wins in the same form as for the Ladder.
- If you activate a version in both modes, the same code has to cope with a fleet of 4 and a fleet of 1. So never write the number 4 in code; read `me.FleetSize`, and do not wait for messages from fleet mates when `me.FleetSize == 1` (chapters 5, 8 and 13).

## 2. The world

- The arena is a walled square with hilly terrain and rocks. Coordinates are in metres: X grows east, Y grows north. Angles are in radians: 0 is +X, and they grow counter-clockwise.
- Time runs in ticks. Every tick the server calls your code for each living shtemer.
- Everyone knows the map: `me.Arena` gives the terrain height at any point and the list of rocks. Rocks are impassable and block both vision and projectiles. Terrain blocks them too: behind a hill you are hidden and protected.
- Zone: a safe circle that moves and shrinks in stages, down to zero. Each stage first holds the circle in place, then moves and shrinks it linearly towards the next circle; the next circle always lies inside the previous one. Outside the zone a shtemer loses health every tick, and more with each stage. `senses.Zone` tells you where the circle is now, where it is heading and when; `Zone.At(ticksAhead)` and `Zone.ContainsAt(point, ticksAhead)` tell you where it will be. At the start the circle may be larger than the arena, in which case the arena wall is the real edge.
- Loot: boxes scattered around the arena (ammo, rockets, repair). A shtemer picks one up by moving close to it. New ones appear inside the zone during the match. A destroyed shtemer leaves a box behind.
- Fleets start spaced out on a circle around the centre of the arena, each fleet's members side by side.

## 3. The shtemer

- It moves in any direction without turning, but it has **inertia**: `me.Thrust(direction)` sets acceleration, not velocity. Drag slows it down, so on flat ground it tops out at `TopSpeed` (see the numbers; at 8 m/s it crosses a 100 m arena in about 12 s, and it takes about a second to reach full speed). Uphill it is slower, downhill faster. To stop, call `Thrust(Vec2.Zero)` and wait, or thrust against `me.Velocity`.
- Collisions with a wall, a rock or another shtemer do no damage, but they stop or push you. The stopping distance from full speed is about 5 m.
- A shtemer standing still takes almost every shot; one moving across the line of fire takes about half. Moving does not spoil your own aim.
- It has two weapons. **Pistol**: fast bullet, low damage, plenty of ammo, short cooldown. **Rocket**: slow, explodes at the point you aimed at (or earlier if it hits something), damage spreads in a circle and falls off with distance; rockets are few. An explosion hurts everything in the circle, you and your own shtemers included.
- **Ammo is scarce.** `PistolStartAmmo` · `PistolDamage` is well below `MaxHealth`: even if every bullet hit, one shtemer's starting ammo would not kill a single opponent. A long fight is therefore decided by loot (`LootAmmoAmount`, `LootRocketAmount`, `LootRepairAmount`), not by what you start with. Don't waste bullets on unchecked blips and distant targets, and build ammo and repair boxes into your plan (`PistolMaxAmmo` and `RocketMaxAmmo` are the most you can carry).
- You fire at a **point**: `me.Fire(weapon, target)`. The projectile flies straight from the muzzle towards that point and on past it, until it hits something or runs out of range. Projectiles are not instantaneous and do not inherit the shooter's velocity: to hit a moving target, aim at `position + velocity · (flight time + one tick)`, because the projectile only starts moving after the target has moved in this tick.
- **Friendly fire is on.** A projectile cannot tell your shtemers from anyone else's.

## 4. What a shtemer sees

There is no shared view. Each shtemer sees only its own:

- **Blips (free).** Everything inside the vision cone (`VisionRange`, angle `2·VisionHalfAngle`) that is not hidden behind terrain or a rock, plus everything within `ProximityRange` of the shtemer, whatever the direction and whatever the cover (proximity is sensed through rocks and hills). A blip tells you only that something is there, roughly where (the error grows with distance) and roughly how big. `Medium` is a shtemer, **yours or someone else's, you can't tell which**. `Small` is a loot box or a rocket in flight (a box sits still, a rocket closes in fast: track the blip across ticks and you can even dodge it). You can't see pistol bullets.
- **The cone turns.** `me.Look(angle)` or `me.LookAt(point)` sets where you look; the cone turns at a limited rate (`LookTurnRate` per tick). While you look forward, you can't see behind you. Scanning is your job, and a fleet can divide the directions among its shtemers.
- **Zoom (costs energy).** `me.Zoom(blip.Id)` spends `ZoomCost` energy. On the next tick you get exact data in `senses.Contacts`, **but only if you can still see the object then**: if you turn the cone away from it in the meantime, the energy is spent and there is no answer. You get: what it is, its exact position and velocity, whether it is an ally, which fleet, its index within the fleet (0 is that fleet's admiral), its health, and what is in the box. Energy regenerates slowly, so you can't zoom everything all the time.
- `Blip.Id` stays the same for as long as the object stays in view without a break. If you lose it and see it again, it gets a new id. A blip has no velocity: you get a target's velocity by zooming or by tracking the blip across ticks. `BlipSize.Large` does not appear yet.
- About allies you know only whether they are alive (`me.IsAllyAlive(i)`). You know where they are only if they tell you by message or you zoom them.
- When something hits you, `OnHit` tells you how much damage it did, what it was, and the direction the projectile was travelling in (the shooter is the other way).

## 5. Fleet and messages

- All instances run the **same code**. They differ by `me.Index` (0 to `FleetSize − 1`). Instance 0 is the admiral (`me.IsAdmiral`). What being the admiral means is up to your code.
- Instances **do not share memory**. Class fields are per instance. Static fields are forbidden.
- The only channel is messages: `me.Send(toIndex, type, data...)` and `me.Broadcast(type, data...)`. A message is an integer `type` and up to `MaxMessageData` numbers (`double`). It arrives at the start of the **next** tick through `OnMessage`, from anywhere in the arena; the data in it is one tick old (`me.Tick` inside `OnMessage` is the delivery tick). At most `MaxMessagesPerTick` messages per shtemer per tick.
- A typical pattern: every tick, every shtemer reports its position and velocity (that is only one of the `MaxMessagesPerTick` messages it may send per tick); whoever sees an unknown `Medium` blip compares it with the reported ally positions (one tick old, so extrapolate them by velocity) before spending a zoom or firing. That way recognising your own costs no energy. On tick 0 nobody yet knows where their fleet mates are, and the fleet starts in a clump: don't fire until the first messages arrive. In SOLO there are no messages (`me.FleetSize == 1`): don't wait for them, or you will never fire.
- The same fleet can appear in a match twice (test matches, house fleets). Two copies of the same code with the same inputs make the same decisions; if that is a problem, break the symmetry with `me.Random`, which differs for every instance.

### War cry: `me.Shout(message)`

A shtemer can shout one message from a **fixed table** of 48 messages (indices 0 to 47; `WarCry.Count` is the number of messages). Players never write text: `me.Shout(16)` is all it takes. **Your code decides when.** The message is pure decoration: it has no effect on the match or on what anybody sees or hears; it just shows up as a small speech bubble above the shtemer in the viewer (in the viewer's language) and is written to the replay and to your fleet's telemetry (shown as `shout#N` by `get_telemetry`). The bubbles are being switched on in the viewer gradually; shouts are recorded from day one, so older matches will show them too once they are on.

- At most **one message per shtemer every 20 seconds** (`me.Rules.WarCryCooldownTicks` = 400 ticks). `me.Shout(i)` returns `true` when the message was accepted, and `false` (doing nothing) for an index outside the table or while the shtemer is still on cooldown. A failed call does not use up the cooldown.
- Indices are stable: new messages are only appended at the end, existing ones are never reordered or removed. You can rely on the number.
- Typical moments: first contact with an enemy, a kill, low health, entering the zone, the last shtemer of the fleet alive.

```csharp
// in OnTick: shout when health is low (16 = "Avenge me!"); the cooldown is kept by me.Shout
if (me.Health < 0.3 * me.Rules.MaxHealth) me.Shout(16);
```

| # | hr | en |
|---|---|---|
| 0 | Naprijed naši! | Charge! |
| 1 | Živio Končar! | For the factory! |
| 2 | Bit će bolno. | This'll hurt. |
| 3 | Stižem! | Incoming! |
| 4 | S ljubavlju. | With love. |
| 5 | Lezi! | Get down! |
| 6 | Imaš 3 sekunde. | You've got 3 seconds. |
| 7 | Kod je kriv! | Blame the code! |
| 8 | Sljedeći! | Next! |
| 9 | Ništa osobno. | Nothing personal. |
| 10 | Žali se LLM-u. | Complain to your LLM. |
| 11 | Uninstall. | Uninstall. |
| 12 | GG ez. | GG ez. |
| 13 | Za statistiku. | For the stats. |
| 14 | Pokušao sam... | I tried... |
| 15 | Bug! Bug! | It's a bug! |
| 16 | Osveti me! | Avenge me! |
| 17 | Vidimo se v2. | See you in v2. |
| 18 | U backlog... | Backlog it... |
| 19 | Metak? | Ammo? |
| 20 | Prazno! | Empty! |
| 21 | Moje! | Mine! |
| 22 | Raketa! | Rocket! |
| 23 | Zakrpan! | Patched! |
| 24 | Bjež'te! | Run! |
| 25 | Vruće! | Hot! |
| 26 | U krug! | Circle up! |
| 27 | Izađi! | Come out! |
| 28 | Vidim te. | I see you. |
| 29 | Kamperu! | Camper! |
| 30 | Ajmo! | Come on! |
| 31 | Otpor je uzaludan. | Resistance is futile. |
| 32 | Crvena uzbuna! | Red alert! |
| 33 | Štitove gore! | Shields up! |
| 34 | Mrtav je, Jim. | He's dead, Jim. |
| 35 | Qapla'! | Qapla'! |
| 36 | Ovo je zamka! | It's a trap! |
| 37 | Loš predosjećaj... | Bad feeling... |
| 38 | Ne govori mi šanse! | Never tell me the odds! |
| 39 | Roger, roger. | Roger, roger. |
| 40 | Ja sam tvoj otac. | I am your father. |
| 41 | Ubij sve ljude! | Kill all humans! |
| 42 | Grizi moj metal! | Bite my shiny metal... |
| 43 | Dobre vijesti! | Good news, everyone! |
| 44 | Vratio sam se! | I'm back, baby! |
| 45 | Zadnji stojim. | Last one up. |
| 46 | GG, ljudi. | GG, humans. |
| 47 | GG! | GG! |

## 6. How a tick works

For every living shtemer, in this order: `OnStart` (first time only, without senses or zone), `OnMessage` for each message that arrived, `OnHit` and `OnCollision` for events from the previous tick, then `OnTick`. The commands you issue (`Thrust`, `Look`, `Fire`, `Zoom`, `Send`) are carried out after every shtemer of every fleet has finished its tick. `Thrust` and `Look` stay in effect until you change them.

A match is deterministic: the same code and the same seed give the same match. Take randomness from `me.Random`.

Placement: a fleet is eliminated when its last shtemer dies; placement is the reverse order of elimination. Fleets eliminated in the same tick, or still alive at the end (`MaxTicks`), are ranked by total remaining health, then by damage dealt.

## 7. Budget and code restrictions

- **Instruction budget.** All calls of one instance in one tick share `InstructionBudget` IL instructions (loops and method calls in your code; a call into the .NET library counts as a single instruction, but such calls are guarded by a wall-clock limit). When you use them up, that instance's tick stops immediately; commands issued up to that point still count. So issue commands early and spread expensive computations over several ticks. For reference: a decent fleet of 500 to 700 lines uses 1,000 to 5,000 per tick; `Arena.HasLineOfSight` costs 60, `Log` 20. Telemetry records the spend every tick.
- **Exceptions.** An uncaught exception ends that instance's tick and is written to telemetry. The shtemer carries on next tick.
- **Allowed:** plain C# 12: classes, structs, records, enums, lambdas, LINQ, collections from `System.Collections.Generic`, `Math`, strings, `StringBuilder`, tuples, pattern matching, try/catch, static methods and `const`. The directives `using System;`, `using System.Linq;` and `using System.Collections.Generic;` are allowed and expected (along with `using Shtemeri.Api;`); the ban applies to forbidden types, not to namespaces.
- **Forbidden** (the server's compiler returns an error with an explanation): static fields and static auto-properties (except `const`), `System.Random` (use `me.Random`), clocks (`DateTime`, `Stopwatch`), files, networking, threads and `Task`, `async/await`, reflection, `typeof`, `GetType()`, attributes, `unsafe`, `dynamic`, `lock`.
- Exactly one public class that derives from `Shtemer`, with a parameterless constructor. Helper classes may be in the same source file.

## 8. This season's numbers

The table lists all of this season's numbers. In code, `me.Rules` carries only those marked `yes` in the last column (these are the properties of the `IRules` interface in the API below); the others (e.g. zone, loot, `SpawnRingRadius`, heights) are fixed facts of the season that you can only read here, so if you need them in code, declare them as `const`. You get the number of shtemers in a fleet as `me.FleetSize`. In SOLO it is 1, although the table says `FleetSize` = 4 (the Ladder); the number 4 written into code is a mistake. Heights: a shtemer's eye is 1.6 m above the terrain, the muzzle 1.2 m, and a projectile aims at 1.0 m above the terrain at the target point; the body is a cylinder of radius `ShtemerRadius` and height 2 m.

| Group | Parameter | Value | me.Rules |
|---|---|---|---|
| Time and budget | `TicksPerSecond` | 20 | yes |
| Time and budget | `MaxTicks` | 3600 | yes |
| Time and budget | `ArenaSize` | 100 | yes |
| Time and budget | `InstructionBudget` | 50000 | yes |
| Match layout | `FleetSize` | 4 | no |
| Match layout | `SpawnRingRadius` | 40 | no |
| Body and movement | `ShtemerRadius` | 1 | yes |
| Body and movement | `ShtemerHeight` | 2 | no |
| Body and movement | `EyeHeight` | 1.6 | no |
| Body and movement | `MuzzleHeight` | 1.2 | no |
| Body and movement | `TargetHeight` | 1 | no |
| Body and movement | `MaxHealth` | 250 | yes |
| Body and movement | `ThrustAcceleration` | 12 | yes |
| Body and movement | `Drag` | 1.5 | yes |
| Body and movement | `TopSpeed` | 8 | yes |
| Body and movement | `SlopeAcceleration` | 6 | yes |
| Body and movement | `ParkingSpeed` | 0.5 | no |
| Body and movement | `ParkingSlopeForce` | 3 | no |
| Senses | `VisionRange` | 32 | yes |
| Senses | `VisionHalfAngle` | 0.87 | yes |
| Senses | `ProximityRange` | 6 | yes |
| Senses | `LookTurnRate` | 0.21 | yes |
| Senses | `BlipNoise` | 0.03 | yes |
| Senses | `MaxEnergy` | 100 | yes |
| Senses | `EnergyRegen` | 0.4 | yes |
| Senses | `ZoomCost` | 8 | yes |
| Senses | `MaxZoomsPerTick` | 3 | yes |
| Pistol | `PistolSpeed` | 40 | yes |
| Pistol | `PistolDamage` | 4 | yes |
| Pistol | `PistolCooldown` | 8 | yes |
| Pistol | `PistolRange` | 30 | yes |
| Pistol | `PistolStartAmmo` | 40 | yes |
| Pistol | `PistolMaxAmmo` | 120 | yes |
| Rocket | `RocketSpeed` | 18 | yes |
| Rocket | `RocketDirectDamage` | 25 | yes |
| Rocket | `RocketSplashDamage` | 35 | yes |
| Rocket | `RocketSplashRadius` | 5 | yes |
| Rocket | `RocketCooldown` | 50 | yes |
| Rocket | `RocketRange` | 45 | yes |
| Rocket | `RocketStartAmmo` | 1 | yes |
| Rocket | `RocketMaxAmmo` | 6 | yes |
| Loot | `PickupRadius` | 1.8 | yes |
| Loot | `LootAmmoAmount` | 30 | yes |
| Loot | `LootRocketAmount` | 2 | yes |
| Loot | `LootRepairAmount` | 50 | yes |
| Loot | `LootInitialBoxes` | 10 | no |
| Loot | `LootSpawnInterval` | 200 | no |
| Loot | `LootSpawnCount` | 3 | no |
| Loot | `LootMaxBoxes` | 16 | no |
| Zone | `ZoneStartRadius` | 50 | no |
| Zone | `ZoneShrinkFactor` | 0.62 | no |
| Zone | `ZoneStages` | 6 | no |
| Zone | `ZoneHoldTicks` | 200 | no |
| Zone | `ZoneShrinkTicks` | 250 | no |
| Zone | `ZoneDamagePerStage` | 0.3 | no |
| Messages and log | `MaxMessageData` | 8 | yes |
| Messages and log | `MaxMessagesPerTick` | 8 | yes |
| Messages and log | `MaxLogLinesPerTick` | 20 | yes |
| Messages and log | `MaxLogLineLength` | 200 | no |
| Messages and log | `WarCryCooldownTicks` | 400 | yes |
| API call costs (instructions) | `CostLineOfSight` | 60 | no |
| API call costs (instructions) | `CostHeightAt` | 5 | no |
| API call costs (instructions) | `CostLog` | 20 | no |

## 9. API

This is the entire API, with comments that explain the behaviour.

```csharp
// ===== Vec2.cs =====
using System.Globalization;

namespace Shtemeri.Api;

/// <summary>2D vector in arena coordinates (metres). X grows east, Y grows north. Angles are radians, 0 = +X, counter-clockwise.</summary>
public readonly struct Vec2 : IEquatable<Vec2>
{
    public readonly double X;
    public readonly double Y;

    public Vec2(double x, double y) { X = x; Y = y; }

    public static readonly Vec2 Zero = new(0, 0);

    public double Length => Math.Sqrt(X * X + Y * Y);
    public double LengthSquared => X * X + Y * Y;

    /// <summary>Angle of this vector in radians (-π..π].</summary>
    public double Angle => Math.Atan2(Y, X);

    /// <summary>Unit vector in the same direction; Zero if this vector is (almost) zero.</summary>
    public Vec2 Normalized()
    {
        double len = Length;
        return len < 1e-9 ? Zero : new Vec2(X / len, Y / len);
    }

    /// <summary>Same direction, length limited to <paramref name="max"/>.</summary>
    public Vec2 ClampLength(double max)
    {
        double len = Length;
        return len <= max || len < 1e-9 ? this : new Vec2(X / len * max, Y / len * max);
    }

    public double DistanceTo(Vec2 other) => (other - this).Length;
    public double Dot(Vec2 other) => X * other.X + Y * other.Y;
    public double Cross(Vec2 other) => X * other.Y - Y * other.X;

    /// <summary>This vector rotated counter-clockwise by <paramref name="radians"/>.</summary>
    public Vec2 Rotated(double radians)
    {
        double c = Math.Cos(radians), s = Math.Sin(radians);
        return new Vec2(X * c - Y * s, X * s + Y * c);
    }

    public static Vec2 FromAngle(double radians, double length = 1.0) =>
        new(Math.Cos(radians) * length, Math.Sin(radians) * length);

    public static Vec2 Lerp(Vec2 a, Vec2 b, double t) => new(a.X + (b.X - a.X) * t, a.Y + (b.Y - a.Y) * t);

    /// <summary>Normalizes an angle to (-π..π].</summary>
    public static double NormalizeAngle(double radians)
    {
        radians %= 2 * Math.PI;
        if (radians > Math.PI) radians -= 2 * Math.PI;
        else if (radians <= -Math.PI) radians += 2 * Math.PI;
        return radians;
    }

    /// <summary>Signed shortest difference to - from, in (-π..π].</summary>
    public static double AngleDiff(double from, double to) => NormalizeAngle(to - from);

    public static Vec2 operator +(Vec2 a, Vec2 b) => new(a.X + b.X, a.Y + b.Y);
    public static Vec2 operator -(Vec2 a, Vec2 b) => new(a.X - b.X, a.Y - b.Y);
    public static Vec2 operator -(Vec2 a) => new(-a.X, -a.Y);
    public static Vec2 operator *(Vec2 a, double k) => new(a.X * k, a.Y * k);
    public static Vec2 operator *(double k, Vec2 a) => new(a.X * k, a.Y * k);
    public static Vec2 operator /(Vec2 a, double k) => new(a.X / k, a.Y / k);
    public static bool operator ==(Vec2 a, Vec2 b) => a.X == b.X && a.Y == b.Y;
    public static bool operator !=(Vec2 a, Vec2 b) => !(a == b);

    public bool Equals(Vec2 other) => this == other;
    public override bool Equals(object? obj) => obj is Vec2 v && this == v;
    public override int GetHashCode() => HashCode.Combine(X, Y);
    public override string ToString() => string.Format(CultureInfo.InvariantCulture, "({0:0.0}, {1:0.0})", X, Y);
}

// ===== Shtemer.cs =====
namespace Shtemeri.Api;

/// <summary>
/// Base class of a fleet. You write ONE class deriving from Shtemer; the server creates one instance of it
/// per shtemer in your fleet. Instances share nothing (no memory, no vision) - they talk only through messages.
/// Instance fields keep their values between ticks. The instance with <see cref="ISelf.Index"/> == <see cref="Fleet.Admiral"/> is the admiral.
///
/// Per tick, for every living instance, the server calls in this order:
/// OnMessage (once per message received), OnHit, OnCollision (events from the previous tick), then OnTick.
/// All calls of one instance in one tick share a single instruction budget (<see cref="IRules.InstructionBudget"/>).
/// When the budget runs out the instance's tick ends immediately; commands issued so far still count.
/// </summary>
public abstract class Shtemer
{
    /// <summary>Called once, before the first OnTick of this instance.</summary>
    public virtual void OnStart(ISelf me) { }

    /// <summary>Called every tick while this shtemer is alive.</summary>
    public abstract void OnTick(ISelf me, ISenses senses);

    /// <summary>A message from a fleet mate, sent during the previous tick.</summary>
    public virtual void OnMessage(ISelf me, Message msg) { }

    /// <summary>This shtemer took projectile or splash damage during the previous tick.</summary>
    public virtual void OnHit(ISelf me, HitEvent e) { }

    /// <summary>This shtemer bumped into a wall, an obstacle or another shtemer during the previous tick.</summary>
    public virtual void OnCollision(ISelf me, CollisionEvent e) { }
}

/// <summary>Fleet-wide constants.</summary>
public static class Fleet
{
    /// <summary>Index of the admiral instance.</summary>
    public const int Admiral = 0;
}

public enum Weapon
{
    /// <summary>Fast, weak, plenty of ammo.</summary>
    Pistol = 0,
    /// <summary>Slow, strong, splash damage, explodes at the target point. Scarce.</summary>
    Rocket = 1,
}

public enum BlipSize { Small = 0, Medium = 1, Large = 2 }

public enum ContactKind { Shtemer = 0, Loot = 1, Rocket = 2 }

public enum LootKind { None = 0, Ammo = 1, Rockets = 2, Repair = 3 }

public enum CollisionKind { Wall = 0, Obstacle = 1, Shtemer = 2 }

// ===== ISelf.cs =====
namespace Shtemeri.Api;

/// <summary>The shtemer's own state and its commands. Valid only during the callback it was passed to.</summary>
public interface ISelf
{
    // ---- identity ----

    /// <summary>Index of this instance in the fleet, 0..FleetSize-1. 0 is the admiral.</summary>
    int Index { get; }
    int FleetSize { get; }
    bool IsAdmiral { get; }

    /// <summary>Whether the fleet mate with the given index is still alive (a fleet always knows who is alive, not where).</summary>
    bool IsAllyAlive(int index);

    // ---- state ----

    /// <summary>Current tick, starting at 0.</summary>
    int Tick { get; }
    Vec2 Position { get; }
    /// <summary>Terrain height under the shtemer, metres.</summary>
    double Altitude { get; }
    /// <summary>Metres per second.</summary>
    Vec2 Velocity { get; }
    double Health { get; }
    /// <summary>Energy pays for <see cref="Zoom"/>. Regenerates every tick.</summary>
    double Energy { get; }
    /// <summary>Direction the vision cone currently points, radians.</summary>
    double LookAngle { get; }

    int Ammo(Weapon weapon);
    /// <summary>Ticks until the weapon can fire again; 0 = ready.</summary>
    int Cooldown(Weapon weapon);

    /// <summary>The static map: terrain heights and obstacles. Fully known to everyone.</summary>
    IArena Arena { get; }
    /// <summary>The numbers of this match (speeds, ranges, damage, costs).</summary>
    IRules Rules { get; }
    /// <summary>Deterministic random numbers, separate for every instance.</summary>
    IRandom Random { get; }

    // ---- commands ----

    /// <summary>
    /// Sets the thrust: direction of acceleration, magnitude 0..1 (longer vectors are clamped to 1).
    /// The shtemer has inertia and drag: it speeds up towards the thrust direction, and coasts to a stop with Vec2.Zero.
    /// The setting persists until changed.
    /// </summary>
    void Thrust(Vec2 direction);

    /// <summary>Sets the desired look angle (radians). The vision cone turns towards it at <see cref="IRules.LookTurnRate"/> per tick. Persists until changed.</summary>
    void Look(double angle);

    /// <summary>Same as Look, towards a point in the arena.</summary>
    void LookAt(Vec2 point);

    /// <summary>
    /// Fires at a point in the arena. The projectile flies in a straight line from the muzzle towards that point
    /// (terrain height at the target included) and keeps going until it hits something or runs out of range;
    /// a rocket explodes when it reaches the target point. Terrain, obstacles and shtemers (friends too) stop projectiles.
    /// Returns false, and does nothing, when the weapon is cooling down or out of ammo. One shot per weapon per tick.
    /// </summary>
    bool Fire(Weapon weapon, Vec2 target);

    /// <summary>
    /// Requests details about a blip (use <see cref="Blip.Id"/> from this tick's senses). Costs <see cref="IRules.ZoomCost"/> energy.
    /// The answer arrives in next tick's <see cref="ISenses.Contacts"/>, if the object is still visible then.
    /// Returns false when there is not enough energy, the id is unknown, or the per-tick zoom limit is reached.
    /// </summary>
    bool Zoom(int blipId);

    /// <summary>
    /// Sends a message to one fleet mate. It is delivered at the start of the next tick, anywhere in the arena.
    /// At most <see cref="IRules.MaxMessageData"/> numbers per message and <see cref="IRules.MaxMessagesPerTick"/> messages per tick; the rest is dropped.
    /// </summary>
    void Send(int toIndex, int type, params double[] data);

    /// <summary>Sends a message to every other living fleet mate. Counts as one message.</summary>
    void Broadcast(int type, params double[] data);

    /// <summary>Writes a line to this shtemer's telemetry log (what the owner reads after the match). Limited per tick.</summary>
    void Log(string text);

    /// <summary>
    /// War cry: the shtemer shouts one of the fixed messages (index 0..<see cref="WarCry.Count"/>-1 into the table in the guide).
    /// It shows up as a small speech bubble above the shtemer in the viewer and is written to the replay; it has no effect on the match.
    /// At most one shout per shtemer every <see cref="IRules.WarCryCooldownTicks"/> ticks (20 seconds).
    /// Returns false, and does nothing, for an index outside the table or while the shtemer is still on cooldown.
    /// </summary>
    // Default body: a class compiled before war cry that implements ISelf itself still loads (REVIEW-6); the real ISelf overrides it.
    bool Shout(int message) => false;
}

/// <summary>The war cry table is fixed: messages are numbered 0..Count-1 and the numbers never change (new ones are only added at the end).</summary>
public static class WarCry
{
    /// <summary>Number of messages in the table (valid indices are 0..Count-1).</summary>
    public const int Count = 48;
}

/// <summary>What the shtemer perceives this tick.</summary>
public interface ISenses
{
    /// <summary>
    /// Free low-resolution vision: everything inside the vision cone (and anything very close, all around)
    /// that is not hidden behind terrain or an obstacle. A blip tells you something is there and roughly how big - not what it is.
    /// </summary>
    IReadOnlyList<Blip> Blips { get; }

    /// <summary>Detailed answers to the <see cref="ISelf.Zoom"/> calls made during the previous tick.</summary>
    IReadOnlyList<Contact> Contacts { get; }

    /// <summary>The shrinking safe zone. Known to everyone.</summary>
    Zone Zone { get; }
}

public interface IArena
{
    /// <summary>The arena is a square [0, Size] x [0, Size], walled.</summary>
    double Size { get; }
    /// <summary>Terrain height at a point, metres.</summary>
    double HeightAt(Vec2 point);
    /// <summary>Rocks: impassable, they block vision and projectiles.</summary>
    IReadOnlyList<Obstacle> Obstacles { get; }
    /// <summary>True when the point is outside the arena or inside an obstacle.</summary>
    bool IsBlocked(Vec2 point);
    /// <summary>True when a shtemer standing at <paramref name="from"/> could see a shtemer standing at <paramref name="to"/> (terrain and obstacles only; range and cone are not checked).</summary>
    bool HasLineOfSight(Vec2 from, Vec2 to);
}

public interface IRandom
{
    /// <summary>Uniform in [0, 1).</summary>
    double NextDouble();
    /// <summary>Uniform integer in [0, maxExclusive).</summary>
    int Next(int maxExclusive);
    /// <summary>Uniform in [min, max).</summary>
    double Range(double min, double max);
}

/// <summary>All numbers of the match. Distances in metres, time in ticks unless said otherwise.</summary>
public interface IRules
{
    int TicksPerSecond { get; }
    int MaxTicks { get; }
    double ArenaSize { get; }
    int InstructionBudget { get; }

    double ShtemerRadius { get; }
    double MaxHealth { get; }
    /// <summary>Acceleration at full thrust, m/s².</summary>
    double ThrustAcceleration { get; }
    /// <summary>Drag coefficient, 1/s. Top speed on flat ground = ThrustAcceleration / Drag.</summary>
    double Drag { get; }
    double TopSpeed { get; }
    /// <summary>Downhill pull per unit of slope, m/s². Uphill is slower, downhill faster.</summary>
    double SlopeAcceleration { get; }

    double VisionRange { get; }
    /// <summary>Half of the vision cone's opening angle, radians.</summary>
    double VisionHalfAngle { get; }
    /// <summary>Anything closer than this is sensed all around, even behind cover.</summary>
    double ProximityRange { get; }
    /// <summary>Radians per tick.</summary>
    double LookTurnRate { get; }
    /// <summary>Blip position error grows with distance: up to Distance * BlipNoise metres.</summary>
    double BlipNoise { get; }

    double MaxEnergy { get; }
    double EnergyRegen { get; }
    double ZoomCost { get; }
    int MaxZoomsPerTick { get; }

    double PistolSpeed { get; }
    double PistolDamage { get; }
    int PistolCooldown { get; }
    double PistolRange { get; }
    int PistolStartAmmo { get; }
    int PistolMaxAmmo { get; }

    double RocketSpeed { get; }
    /// <summary>Extra damage to the shtemer a rocket hits directly (on top of splash).</summary>
    double RocketDirectDamage { get; }
    /// <summary>Splash damage at the centre of the explosion; falls linearly to 0 at RocketSplashRadius.</summary>
    double RocketSplashDamage { get; }
    double RocketSplashRadius { get; }
    int RocketCooldown { get; }
    double RocketRange { get; }
    int RocketStartAmmo { get; }
    int RocketMaxAmmo { get; }

    double PickupRadius { get; }
    int LootAmmoAmount { get; }
    int LootRocketAmount { get; }
    double LootRepairAmount { get; }

    int MaxMessageData { get; }
    int MaxMessagesPerTick { get; }
    int MaxLogLinesPerTick { get; }

    /// <summary>Minimum ticks between two war cries of one shtemer (<see cref="ISelf.Shout"/>).</summary>
    int WarCryCooldownTicks => 400;   // default body for classes compiled before war cry that implement IRules (REVIEW-6)
}

// ===== Data.cs =====
namespace Shtemeri.Api;

/// <summary>Something seen in low resolution. You do not know what it is or whose it is until you zoom.</summary>
public sealed class Blip
{
    /// <summary>
    /// Tracking id, private to the observing shtemer. Stays the same for as long as the object stays visible to it;
    /// an object that was lost and seen again gets a new id.
    /// </summary>
    public int Id { get; }
    /// <summary>Approximate position; the error grows with distance.</summary>
    public Vec2 Position { get; }
    public double Distance { get; }
    /// <summary>Direction from the observer to the blip, radians.</summary>
    public double Bearing { get; }
    /// <summary>Shtemers are Medium. Loot boxes and rockets in flight are Small. Pistol bullets are not visible.</summary>
    public BlipSize Size { get; }

    public Blip(int id, Vec2 position, double distance, double bearing, BlipSize size)
    {
        Id = id; Position = position; Distance = distance; Bearing = bearing; Size = size;
    }
}

/// <summary>The detailed answer to a Zoom.</summary>
public sealed class Contact
{
    /// <summary>The blip this answer belongs to.</summary>
    public int BlipId { get; }
    public ContactKind Kind { get; }
    /// <summary>Exact position.</summary>
    public Vec2 Position { get; }
    /// <summary>Metres per second.</summary>
    public Vec2 Velocity { get; }
    /// <summary>For shtemers: is it from your own fleet.</summary>
    public bool IsAlly { get; }
    /// <summary>For shtemers: number of the fleet (0..fleets-1). -1 otherwise.</summary>
    public int FleetId { get; }
    /// <summary>For shtemers: index inside its fleet (0 = that fleet's admiral). -1 otherwise.</summary>
    public int Index { get; }
    /// <summary>For shtemers: health. 0 otherwise.</summary>
    public double Health { get; }
    /// <summary>For loot boxes: what is inside. None otherwise.</summary>
    public LootKind Loot { get; }

    public Contact(int blipId, ContactKind kind, Vec2 position, Vec2 velocity, bool isAlly, int fleetId, int index, double health, LootKind loot)
    {
        BlipId = blipId; Kind = kind; Position = position; Velocity = velocity;
        IsAlly = isAlly; FleetId = fleetId; Index = index; Health = health; Loot = loot;
    }
}

/// <summary>
/// The safe zone is a circle that shrinks in stages: it holds, then moves and shrinks towards the next circle.
/// Outside the zone a shtemer loses <see cref="DamagePerTick"/> health every tick. In the end the zone closes completely.
/// </summary>
public readonly struct Zone
{
    public Vec2 Center { get; }
    public double Radius { get; }
    /// <summary>The circle the zone is shrinking (or will shrink) to.</summary>
    public Vec2 NextCenter { get; }
    public double NextRadius { get; }
    /// <summary>Ticks until the zone starts to shrink towards the next circle; 0 while it is shrinking.</summary>
    public int TicksToShrinkStart { get; }
    /// <summary>Ticks until the zone reaches the next circle.</summary>
    public int TicksToShrinkEnd { get; }
    /// <summary>Health lost per tick outside the zone right now. Grows with every stage.</summary>
    public double DamagePerTick { get; }

    public Zone(Vec2 center, double radius, Vec2 nextCenter, double nextRadius, int ticksToShrinkStart, int ticksToShrinkEnd, double damagePerTick)
    {
        Center = center; Radius = radius; NextCenter = nextCenter; NextRadius = nextRadius;
        TicksToShrinkStart = ticksToShrinkStart; TicksToShrinkEnd = ticksToShrinkEnd; DamagePerTick = damagePerTick;
    }

    public bool Contains(Vec2 point) => point.DistanceTo(Center) <= Radius;
    /// <summary>Will the point still be safe once the zone reaches the next circle.</summary>
    public bool NextContains(Vec2 point) => point.DistanceTo(NextCenter) <= NextRadius;

    /// <summary>
    /// Where the zone will be in <paramref name="ticksAhead"/> ticks, assuming the schedule known now: it holds until
    /// TicksToShrinkStart, then moves linearly to the next circle, reaching it at TicksToShrinkEnd. Further ahead than that
    /// the answer is the next circle itself (the stage after it is not known yet).
    /// </summary>
    public (Vec2 Center, double Radius) At(int ticksAhead)
    {
        if (ticksAhead <= TicksToShrinkStart || TicksToShrinkEnd <= TicksToShrinkStart) return (Center, Radius);
        if (ticksAhead >= TicksToShrinkEnd) return (NextCenter, NextRadius);
        double t = (ticksAhead - TicksToShrinkStart) / (double)(TicksToShrinkEnd - TicksToShrinkStart);
        return (Vec2.Lerp(Center, NextCenter, t), Radius + (NextRadius - Radius) * t);
    }

    /// <summary>Will the point be inside the zone in <paramref name="ticksAhead"/> ticks (see <see cref="At"/>).</summary>
    public bool ContainsAt(Vec2 point, int ticksAhead)
    {
        var (c, r) = At(ticksAhead);
        return point.DistanceTo(c) <= r;
    }
}

public readonly struct Obstacle
{
    public Vec2 Center { get; }
    public double Radius { get; }
    public Obstacle(Vec2 center, double radius) { Center = center; Radius = radius; }
}

/// <summary>A message from a fleet mate.</summary>
public sealed class Message
{
    /// <summary>Index of the sender.</summary>
    public int From { get; }
    /// <summary>Whatever the sender put there; the meaning is up to your code.</summary>
    public int Type { get; }
    public IReadOnlyList<double> Data { get; }

    public Message(int from, int type, IReadOnlyList<double> data) { From = from; Type = type; Data = data; }

    /// <summary>Reads Data[offset], Data[offset + 1] as a vector.</summary>
    public Vec2 GetVec(int offset = 0) => new(Data[offset], Data[offset + 1]);
}

public readonly struct HitEvent
{
    public double Damage { get; }
    public Weapon Weapon { get; }
    /// <summary>True for splash damage of an explosion, false for a direct hit.</summary>
    public bool Splash { get; }
    /// <summary>Unit vector the projectile was travelling along (the shooter is in the opposite direction). For splash: from the explosion towards you.</summary>
    public Vec2 Direction { get; }

    public HitEvent(double damage, Weapon weapon, bool splash, Vec2 direction)
    {
        Damage = damage; Weapon = weapon; Splash = splash; Direction = direction;
    }
}

public readonly struct CollisionEvent
{
    public CollisionKind Kind { get; }
    /// <summary>Unit vector pointing away from the thing you hit.</summary>
    public Vec2 Normal { get; }

    public CollisionEvent(CollisionKind kind, Vec2 normal) { Kind = kind; Normal = normal; }
}
```

## 10. An example fleet

```csharp
// opis: Flota ide u središte sljedeće zone i stane u mali krug, svaki shtemer gleda na svoju stranu; zumira srednje blipove i puca samo u potvrđene neprijatelje.
// description: The fleet heads for the centre of the next zone and forms a small circle, each shtemer watching its own side; it zooms medium blips and fires only at confirmed enemies.
using System.Collections.Generic;
using Shtemeri.Api;

namespace Shtemeri.Bots.Kamper;

public sealed class Kamper : Shtemer
{
    private const double RingRadius = 3.0;      // distance of every post from the zone centre
    private const int ZoomGapTicks = 3;         // the answer to a zoom comes next tick; do not pay for the same blip again before that

    private enum Side { Unknown, Ally, Enemy }

    // What this shtemer has learned about one blip id. Blip ids are private to each shtemer, so nothing here is shared with mates.
    private sealed class Track
    {
        public Side Side;
        public Vec2 Velocity;       // from the zoom answer, used to lead the shot
        public int LastZoom = -100;
        public int LastSeen;
    }

    private readonly Dictionary<int, Track> _tracks = new Dictionary<int, Track>();
    private readonly List<Vec2> _mates = new List<Vec2>();     // where the identified mates are this tick, to keep them out of the line of fire
    private double _threatAngle;
    private int _threatTick = -1000;

    // A bullet that hit us tells where it came from: the shooter is the opposite way of its flight.
    public override void OnHit(ISelf me, HitEvent e)
    {
        if (e.Splash) return;       // for splash the direction only points away from the explosion, not towards the shooter
        _threatAngle = (-e.Direction).Angle;
        _threatTick = me.Tick;
    }

    public override void OnTick(ISelf me, ISenses senses)
    {
        LearnFromZooms(senses);
        TakePost(me, senses.Zone);
        Engage(me, senses);
        Cry(me);
    }

    // War cry (me.Shout): a shout changes nothing in the match, it only shows as a speech bubble in the viewer. The number is a row of the
    // table in the guide (28 = "I see you."); me.Shout refuses by itself while the shtemer is still on its 20 second cooldown.
    private bool _cried;

    private void Cry(ISelf me)
    {
        if (_cried) return;
        foreach (var track in _tracks.Values)
        {
            if (track.Side != Side.Enemy || me.Tick - track.LastSeen > 2) continue;
            _cried = me.Shout(28);
            return;
        }
    }

    private void LearnFromZooms(ISenses senses)
    {
        foreach (var contact in senses.Contacts)
        {
            if (contact.Kind != ContactKind.Shtemer || !_tracks.TryGetValue(contact.BlipId, out var track)) continue;
            track.Side = contact.IsAlly ? Side.Ally : Side.Enemy;
            track.Velocity = contact.Velocity;
        }
    }

    private void TakePost(ISelf me, Zone zone)
    {
        // Mates stand evenly around the centre of the NEXT zone, which is inside the current one all the way, so the post is always safe.
        // In the last stage the zone has no radius at all; the floor keeps the mates from piling on one spot.
        double ring = System.Math.Max(me.Rules.ShtemerRadius * 1.5, System.Math.Min(RingRadius, zone.NextRadius * 0.5));
        double slot = 2 * System.Math.PI * me.Index / me.FleetSize;
        Vec2 post = zone.NextCenter + Vec2.FromAngle(slot, ring);
        Vec2 toPost = post - me.Position;

        // Thrust grows with the distance and is cut by the speed, so the shtemer brakes in time instead of overshooting.
        me.Thrust(toPost * 0.1 - me.Velocity * 0.06);

        if (me.Tick - _threatTick < 40) me.Look(_threatAngle);          // shot from outside the cone: face the shooter
        else if (toPost.Length > 4) me.LookAt(post);                    // still walking: look where we are going
        else me.Look(slot);                                             // on post: look outward, every mate in a different direction
    }

    private void Engage(ISelf me, ISenses senses)
    {
        Blip? enemy = null, unknown = null;
        _mates.Clear();
        foreach (var blip in senses.Blips)
        {
            if (blip.Size != BlipSize.Medium) continue;     // loot and rockets in flight are not worth a zoom or a bullet

            if (!_tracks.TryGetValue(blip.Id, out var track))
            {
                track = new Track();
                _tracks[blip.Id] = track;
            }
            track.LastSeen = me.Tick;

            if (track.Side == Side.Ally)
            {
                _mates.Add(blip.Position);
            }
            else if (track.Side == Side.Enemy)
            {
                if (enemy == null || blip.Distance < enemy.Distance) enemy = blip;
            }
            else if (track.Side == Side.Unknown && me.Tick - track.LastZoom >= ZoomGapTicks)
            {
                if (unknown == null || blip.Distance < unknown.Distance) unknown = blip;
            }
        }

        // Zoom costs energy, so only the nearest unidentified blip gets it.
        if (unknown != null && me.Zoom(unknown.Id)) _tracks[unknown.Id].LastZoom = me.Tick;
        if (enemy != null) Shoot(me, enemy, _tracks[enemy.Id]);
        Forget(me.Tick);
    }

    private void Shoot(ISelf me, Blip enemy, Track track)
    {
        IRules rules = me.Rules;
        Vec2 pistolAim = enemy.Position + track.Velocity * (enemy.Distance / rules.PistolSpeed);
        if (LineOfFireClear(me, pistolAim)) me.Fire(Weapon.Pistol, pistolAim);

        // A rocket also splashes whoever stands within its radius, our own post included: use it only on a well distant enemy.
        Vec2 rocketAim = enemy.Position + track.Velocity * (enemy.Distance / rules.RocketSpeed);
        if (me.Ammo(Weapon.Rocket) > 0 && enemy.Distance > rules.RocketSplashRadius * 2 && enemy.Distance < rules.RocketRange / 2
            && LineOfFireClear(me, rocketAim))
            me.Fire(Weapon.Rocket, rocketAim);
    }

    // Friendly fire is on and the mates stand close together: a shot whose path passes through a mate hurts the mate, and a rocket
    // explodes on the first body it meets. So hold fire while a known mate is near the path between here and the target.
    private bool LineOfFireClear(ISelf me, Vec2 target)
    {
        Vec2 from = me.Position;
        Vec2 shot = target - from;
        double length = shot.Length;
        foreach (Vec2 mate in _mates)
        {
            Vec2 toMate = mate - from;
            double along = toMate.Dot(shot) / length;               // how far down the path the mate stands
            double aside = System.Math.Abs(toMate.Cross(shot)) / length;   // how far beside the path
            // A bullet is a point: it hits whatever is within one body radius of its path; the extra half metre covers blip noise.
            if (along > 0 && along < length && aside < me.Rules.ShtemerRadius + 0.5) return false;
        }
        return true;
    }

    // A blip that is lost gets a new id when it is seen again, so old entries are dead weight.
    private void Forget(int tick)
    {
        if (tick % 20 != 0) return;
        var stale = new List<int>();
        foreach (var pair in _tracks)
        {
            if (tick - pair.Value.LastSeen > 20) stale.Add(pair.Key);
        }
        foreach (int id in stale) _tracks.Remove(id);
    }
}
```

The leading `// opis:` (Croatian description), `// description:` (English description) and `// ime:` (name) lines in the example are a house-fleet convention (the server reads them only for house fleets). In your own fleet they are ordinary comments, so feel free to leave them out. You name the fleet when you submit it (the `name` argument).

## 11. How to analyse a match

- `me.Log("...")` is your most valuable tool. Record **why** a shtemer decided something (“I see 3 blips, 2 match reported ally positions, zooming the third”), not just what it did. The log is limited to `MaxLogLinesPerTick` lines per tick.
- After a match, start with the summary (`get_match`): which shtemers were lost, when and to what, and whether there were any exceptions or budget overruns. Then the timeline (`get_timeline`; for a long match it is truncated, and `from_tick` and `to_tick` select a part of it), and only then the detailed telemetry around the interesting moments (`get_telemetry` with a tick range and a single shtemer).
- Telemetry contains only what your shtemers knew. If the player says “the blue one was waiting for you behind the hill”, that is information the telemetry does not have; use it.
- For a view of the whole arena, including opponents your shtemers never saw, use the replay: `get_replay` (chapter 12).
- Change one thing per version and test it on the same seed against the same opponents. Only then can you tell whether the change helped.
- Without LLM tools: on the match page, download the telemetry or copy the summary, and paste it into the conversation.
- **Telemetry has a size limit: 32 MB uncompressed per fleet in one match.** A file that hits the limit ends with a `{"type":"truncated","tick":N}` line, where N is the first tick that was not recorded; after that there are no records until the end of the match (the result line is still written), so a silent end does not mean that nothing happened. `get_telemetry` and the match page warn about it. By far the biggest part of the file is the messages between your shtemers: each message is recorded once when it is sent and once for every receiver. So send compact messages (a few numbers, rounded) rather than your whole state every tick.

### Telemetry download (telemetry v1)

`get_telemetry` gives compact text, one line per tick and shtemer, and for a first look it is the best choice. When you want the **whole file** (to process it with a script, compare several matches, or find something the summary skips), call `get_telemetry_download`. It takes `match_id` (one match) or `last` from 1 to 20 (the last N published matches, newest first; with `fleet` only that fleet's matches), and returns for each file a signed URL, its expiry time, size, number of ticks and SHA-256. Example call: `get_telemetry_download` with `{"fleet": "Moja", "last": 5}`.

The link works **without a login**, but only briefly: it is valid for 15 minutes from the call. An expired or altered link gives 403; ask for a new one with the same tool. Links exist only for your own fleets, and for a ranked, SOLO or cup match only after it has finished airing. Fetch it with `curl -L -o f.jsonl.gz "<url>"` (no `--compressed`: the file is gzip and is saved as it is; its SHA-256 must match the one given).

The format is gzip **JSON Lines**: the first line is a header (`{"type":"header","fleetId":..,"fleetSize":..,"seed":..,"rules":{..},"arena":{..}}`), then one line per tick (`{"t":412,"zone":[cx,cy,r],"i":[...]}` with one entry per shtemer: position, velocity, hp, energy, blips, zoom answers, messages, commands, hits, log, budget, error), and the last line is the result (`{"type":"result","place":..,"stats":{..}}`). Empty fields are omitted. If the file reached its size limit, a line `{"type":"truncated","tick":N}` comes before the result: from tick N on there are no records. The format is **stable within v1**: fields are only ever added, never changed or removed, so read it tolerantly (ignore fields you do not know).

## 12. Replay (god view)

The replay is the match as a spectator sees it in the browser: position, look direction, health and energy of **every** shtemer in every fleet, projectiles, boxes, the zone and all events. Telemetry is the opposite: only what your shtemers knew. Both are legal to use. Telemetry tells you why a shtemer did something; the replay tells you what was actually going on and what it could not see (where the opponent was, who hit whom). For analysis it is worth reading both.

**When it is available.** The replay is available once the match is published: a test match right after it finishes, a ranked, SOLO or cup match only when it has finished airing (until then only a spectator in the browser sees it, live). It is kept for a limited time; after that the server says it was deleted. A private test match is read by its owner and by anyone you give the link with the key (`?k=`); a public one by anyone.

**The `get_replay` tool.** It returns the replay as compact text in windows, so that it fits the size cap of an answer: a header (seed, arena, obstacles, fleets with tags `F0`, `F1`, ...), then for every frame shown one line per living shtemer (`t<tick> g<gid> F<fleet> (x,y) look=<angle> hp=<health> en=<energy>`) and the events in tick order. Arguments: `match_id` (required), `from_tick` and `to_tick` (the window), `stride` (ticks between the frames shown; default the smallest at which the answer fits), `fleet` (only that fleet, any fleet in the match, opponents too), `shtemer` (index within that fleet), `include` (`frames`, `projectiles`, `loot`, `zone`, `events`; default `frames,zone,events`) and `share_key` (for somebody else's private test match). The answer always says which window and stride it shows and which `from_tick` asks for the next one. For a long match, take the whole of it first with the default stride, then ask for narrow windows around the interesting moments.

**Tags in the text.** `gid` is the global shtemer id: `gid = slot * fleet size + index` (with 4 shtemers per fleet, shtemer 2 of the fleet in slot 3 has gid 14). The tag `F3` is the fleet in slot 3. A dead shtemer has no line.

### Replay JSON, format v1

For LLMs that have file or HTTP tools: the same data as JSON, with nothing lost.

- Address: `GET /api/matches/<id>/replay` (`/api/matches/<id>/replay?k=<key>` for a private test match). The exact address is in the `Replay JSON:` line of `get_match`, and in `GET /api/matches/<id>` it is the field `replayUrl` (for a private test match it carries `?k=<key>` only when you are the owner or sent the detail request with the right `?k=`; otherwise append it yourself). The response is `application/json` with `Content-Encoding: gzip`; HTTP clients usually inflate it themselves, and if you get gzip bytes, inflate them (`gunzip`).
- Inflated, it is a few hundred KB to a few MB; most of it is `frames` and `arena.heights`. Do not paste it whole into the conversation: load it with a program (e.g. Python `json`) and pull out what you need.
- **Stability.** The format is marked by the field `version` (now `1`). Within version 1, fields are only added; none will be renamed, removed or change meaning. Ignore fields you do not know. An incompatible change would get `version` 2.

```json
{
  "version": 1, "seed": 123456, "ticksPerSecond": 20, "frameStride": 2, "totalTicks": 3120,
  "rules": { "arenaSize": 100, "fleetSize": 4, "shtemerRadius": 1.0, "visionRange": 32, "visionHalfAngle": 0.87, "proximityRange": 6, "rocketSplashRadius": 5, "maxHealth": 250, "pistolSpeed": 40, "pistolRange": 30, "rocketSpeed": 18, "rocketRange": 45 },
  "arena": { "size": 100, "gridN": 101, "heights": [0.0, 0.12], "obstacles": [[30.1, 40.5, 2.2]] },
  "zoneStages": [[50, 50, 50], [48.2, 51.0, 30]],
  "fleets": [ { "id": 0, "name": "Kamper", "owner": "kuća", "color": "#e6194b" } ],
  "frames": [ { "t": 0, "z": [50, 50, 50], "s": [[46.05, 13.07, 1.67, 250, 100], null], "p": [[7, 0, 40.1, 22.3, 1.2, 3]], "l": [[2, 3, 61.5, 12.0]] } ],
  "events": [ { "t": 12, "k": "fire", "s": 3, "w": 0, "x": 51.2, "y": 40.0 } ],
  "result": { "placements": [3, 0, 5, 2, 4, 1], "stats": [ { "fleet": 0, "place": 2, "kills": 3 } ] }
}
```

- `version`, `seed`, `ticksPerSecond`, `totalTicks`: the format, the seed of the match, ticks per second, the number of ticks (ticks are `0` to `totalTicks - 1`).
- `frameStride`: a frame is written every `frameStride`-th tick (`t` = 0, 2, 4, ...), and the last tick of the match always has a frame, so the last gap may be shorter. Events are written at the tick where they happened, not only at the ticks of frames.
- `rules`: the numbers of this match that drawing needs (`arenaSize`, `fleetSize`, `shtemerRadius`, `visionRange`, `visionHalfAngle` in rad, `proximityRange`, `rocketSplashRadius`, `maxHealth`, speeds and ranges of bullets and rockets). All the numbers of the season are in the table in chapter 8.
- `arena`: `size` (the side of the square in metres), `gridN` (the number of height grid points per side), `heights` (`gridN * gridN` terrain heights, row by row: the height at `(ix, iy)` is `heights[iy * gridN + ix]`, and the grid point is at `(ix * size / (gridN - 1), iy * size / (gridN - 1))`), `obstacles` (rocks as `[x, y, radius]`).
- `zoneStages`: zone circles `[cx, cy, radius]`. Index 0 is the start circle, and index `i` is the circle the zone shrinks to in stage `i`. The zone circle at any moment is in `frames[].z`.
- `fleets`: `id` is the slot of the fleet (0, 1, ...), `name` the fleet name (copies of the same fleet as `Name (v1, slot 1)`), `owner` the owner (`kuća` for house fleets), `color` the colour the viewer uses.
- `frames`: each has `t` (the tick), `z` (the zone `[cx, cy, radius]` at that tick), `s` (one entry per shtemer, in `gid` order; `null` when the shtemer is dead, otherwise `[x, y, look, health, energy]`; `look` is an angle in radians), `p` (projectiles in flight: `[id, kind, x, y, z, owner]`; `kind` 0 is pistol, 1 rocket; `owner` is the `gid` of the shooter) and `l` (boxes: `[id, kind, x, y]`; `kind` 1 Ammo, 2 Rockets, 3 Repair).
- `events`, ordered by `t`; `k` is the kind, and the fields are:
  - `fire`: `s` shooter (gid), `w` weapon (0 pistol, 1 rocket), `x`, `y` the target point.
  - `hit`: `s` the one hit (gid), `by` the shooter (always a gid; damage from the zone has no `hit` event, it shows only as a drop in health in `frames[].s` and as a `death` with `by` `-1`), `w` weapon, `d` damage, `splash` whether the damage is from an explosion.
  - `boom`: `x`, `y`, `z` where a rocket exploded, `by` the shooter (gid).
  - `death`: `s` the one who died (gid), `by` the cause (gid, or `-1`), `cause` is `pistol`, `rocket` or `zone`.
  - `pickup`: `s` (gid), `loot` the kind of box (as `kind` in `l`), `id` the id of the box.
  - `zoom`: `s` (gid), `x`, `y` the true position of the zoomed object.
  - `crash`: `f` the fleet that crashed (slot), `msg` the message.
  - `eliminated`: `f` the fleet that is out (slot), `place` its place.
  - `shout`: `s` the shouting shtemer (gid), `m` the message index in the war cry table (0 to 47, see chapter 5). Added within version 1; older readers ignore it.
- `result`: `placements` are fleet ids (slots) from the winner to the last; `stats` has an entry per fleet: `fleet`, `place`, `kills`, `damageDealt`, `damageTaken`, `shotsPistol`, `shotsRocket`, `hits`, `loot`, `survivedTicks`, `budgetExceeded`, `errors`, `crashed`.
- Rounding: positions and the zone 2 decimals, angles 2, health and energy 1, damage 1.
- `gid` is as above: `gid = fleet id * rules.fleetSize + shtemer index`; `frames[].s[gid]` is the shtemer with that `gid`.

## 13. Common mistakes

- Shooting at every `Medium` blip without checking: half of them are yours.
- Only ever looking forward: you won't see an attack from behind until it is inside `ProximityRange`.
- Aiming at the current position of a moving target. The projectile takes time to get there; aim at `position + velocity · (flight time + one tick)`.
- Wasting ammo: firing at distant or unchecked targets drains a supply that only loot refills (see “Ammo is scarce”).
- Firing a rocket at a target close to you or to your own shtemers.
- Forgetting the zone: check `senses.Zone.NextContains(me.Position)` early; a shtemer is not fast.
- The whole fleet in a clump: one rocket hits them all.
- Leaving every decision to the admiral, and then the admiral dies. Check `me.IsAllyAlive(Fleet.Admiral)` and have a successor.
- An expensive computation every tick: the budget runs out before `Thrust`/`Fire`.
- Code that, in SOLO, waits for messages from fleet mates or hands out roles by index, so nobody fires. Check `me.FleetSize` and, in a fleet of 1, play every role yourself.
