# Endgate SECS and Contracts Boundary

> Describes commands and snapshots **as implemented today** (instant build,
> manual `wave.start_next`, same-wave retry). Target design: see
> [`systems/`](systems/). Index: [`README.md`](README.md).

This note describes what Endgate currently backs with authored SECS content,
what still lives as runtime-hot host state, and how that state crosses the
server/frontend boundary.

## What is SECS-backed today

- `run_state.secs` backs the durable run shell.
- `defender_root.secs` backs the durable defender progression surface:
  position, life state, hit points, damage, range, cadence, movement speed,
  armor, target priority, special cooldown, and revive cost.
- `base_objective.secs` backs the base objective shell.
- `wave_settings.secs` backs global horde defaults (`SpawnBudget`, `SpawnCadenceTicks`) and the `OpeningAssault` template, aligned with `wave-01-breach-probe`.
- `wave_script_fields.secs` and `wave_scripts.secs` back the authored per-wave script catalog (budget, spawn rate, rewards, spawn arcs, goblin template mix). Ten scripts (`wave-01` … `wave-10`) ship today; runs beyond wave 10 reuse the highest authored script until the catalog grows. The compiler emits the wave script author types; the retained `Generated/WaveScripts.cs` file provides host-facing lookup helpers.
- `village_buildings.secs` backs the village build-grid root.

Those authored inputs are compiled through
`examples/endgate/Generated/Endgate.Generated.csproj`, which invokes `secsc` and
exposes `Endgate.Generated.SecsModule.BuildSchema()`. Retained files under
`examples/endgate/Generated/` are non-schema host/catalog helpers only; they may
reference compiler-emitted IDs and author types but must not construct schema
row families.

## What stays runtime-hot

Goblins and characters are SECS entities, but the values that change every tick
or scale with active bodies stay in thin host-side runtime wrappers instead of
the authored SECS shell.

- Each goblin's live position, contact cooldown, flow band, and pool-slot state
  live in its `EnemyRuntime` wrapper inside the recycling `enemyPool` /
  `activeEnemySlots` structures. The goblin itself is a SECS entity on the
  `EnemyRoot` scope; the wrapper recovers it from its `EntityHandle`.
- Corpse/fade and combat-event feedback stay in bounded host-owned runtime
  ring buffers. They are projected for rendering, not stored as durable
  authored entities.
- Each character's live position and per-character cooldowns live in its
  `CharacterRuntime` wrapper; the character itself is a SECS entity on the
  `CharacterRoot` scope. The whole roster projects through one character
  snapshot, upgrade command contract, and explicit save payload.
- Map runtime data such as blocked cells, active edge spawn arcs, walkable
  areas, terrain movement cost, and horde flow fields is generated from the
  current seed and kept in host-owned runtime structures.
- Combat resolution, kill counts, wave queue counts, base damage, energy,
  character unlocks, character upgrades, downed persistence, revive spending,
  special-skill cooldowns, and feedback-event expiry remain host authority
  state.

Run phase and retry identity are low-cardinality run state, so they are authored
in `run_state.secs` and compiler-emitted as integer channels. The server
snapshot projects those channels as both machine codes and readable strings:

- `phase` / `phaseCode`: `planning`, `combat`, `paused`, `wave_cleared`,
  `wave_failed_retry_pending`
- `lastWaveOutcome` / `lastWaveOutcomeCode`: `none`, `in_progress`, `cleared`,
  `failed`
- `currentWave`, `waveAttempt`, `lastCompletedWave`, `lastFailedWave`
- `retryPending`, `canStartNextWave`, `canStartRetryWave`
- `retryWaveEnemyCount`, `retryWaveSpawnRate`, `retryWaveScriptId`
- `lastWaveScriptId`

## How enemies run as SECS entities under horde pressure

Every goblin is a SECS entity on the `EnemyRoot` scope, created through
`CreateEntity(ContractEnemy, templateId)` and advanced through the
`HostileMovement` and `HostileBehavior` policies and the activity executor. The
hot per-tick values that scale with body count — position, contact cooldown,
flow band, pool slot — live in a thin `EnemyRuntime` wrapper rather than the
authored shell.

- The host runs at a base rate of 20 ticks per second.
- The enemy pool's live-concurrency cap is `EndgateHostOptions.MaxLiveEnemies`
  (default 128), the number of goblin entities alive simultaneously.
- The UI and benchmarks drive 1,000, 5,000, and 10,000 body wave sizes. A wave
  budget above the live cap is honored in full and streams through the pool over
  ticks as freed slots recycle.

Keeping each goblin as a real SECS entity gives the horde the same channel
vocabulary, contract, and activity surface as the player roster, while the
`EnemyRuntime` wrapper and recycling pool bound the hot mutation cost. The live
bodies project as one compact render batch.

## Server contract surfaces

The server exposes these query IDs:

- `state.live`
- `map.snapshot`
- `build.preview`
- `save.export`

The server exposes these command IDs:

- `run.start`
- `run.pause`
- `run.resume`
- `run.reset`
- `wave.start_next` with optional `{ scriptId?, waveSize?, spawnRate? }`.
  Omitting args is valid and starts the server-authored planned wave or saved
  retry wave without requiring the browser to send hidden script identity.
  `scriptId`, when supplied outside retry, must name a known host-authored wave
  script. This is reserved for dev/stress flows; unknown script ids are
  rejected with `wave.script.invalid`.
  `waveSize` is optional and is used only for explicit stress drills that scale
  the selected script. `spawnRate` is optional; when supplied it must be an
  integer in [1, 512] and governs how many enemies are activated per simulation
  tick. If `retryPending` is true, this command retries the saved failed wave
  script instead of advancing or accepting a new script, wave size, or cadence.
- `character.move_to` with `{ characterId, x, y }`
- `village.zone.purchase` with `{ zoneId }`
- `building.place` with `{ buildingId, x, y, rotationTurns }`
- `building.wall_chain.place` with ordered `{ cells: [{ gridX, gridY }, ...] }`
  cells (max 160) for one contiguous wall run
- `building.wall_gate.toggle` with `{ gridX, gridY }` to flip one wall cell
  between blocking wall and passable gate
- `building.wall_tower.place` with `{ gridX, gridY }`
- `building.wall_run.upgrade` / `building.wall_run.demolish` with
  `{ gridX, gridY }`, addressing the connected run that contains the cell
- `building.upgrade`, `building.repair`, and `building.demolish` with
  `{ instanceId }`
- `building.relocate` with `{ instanceId, x, y, rotationTurns }`
- `building.rotate` with `{ instanceId, rotationTurns }`
- `upgrade.village.spend_energy` with `{ upgradeId }`
- `upgrade.character.spend_energy` with `{ characterId, skillId }`
- `simulation.set_speed` with `{ speed }`
- `map.regenerate` with `{ seed }`
- `save.load` with `{ save }`

`GameOperationRouter` validates these envelopes, mutates `EndgateGameHost`, then
returns the newly projected `EndgateRootSnapshot`.

`save.export` returns `EndgateSaveGameState` only when the host has no queued or
active enemies. The payload is a server-owned durable checkpoint for planning,
wave-cleared, and failed/retry states. It stores version, seed, tick, run shell,
current wave and attempt, last wave outcome, retry target, energy and total
earned, objective integrity, village upgrade levels, owned village zones,
placed buildings and wall cells, building damage and levels, full roster
unlock/downed/upgrade/stat/cooldown state, kill/escape counters, and simulation
speed. It does not serialize active enemies, corpses, or combat-event buffers;
those are hot runtime facts and are deliberately rebuilt empty after load.

`save.load` deserializes that payload on the server, rejects unsupported
versions or invalid state, rebuilds the deterministic map from the saved seed,
restores village/building/roster progression, recreates the saved retry script
from `retryWaveScriptId`, and returns a fresh root snapshot. The client never
derives gameplay state from the save payload on its own.

## Snapshot boundary

Inside the host, the authoritative read model is `EndgateSimulationState`.
`EndgateSnapshotProjector` converts that host model into the server/frontend
contract:

- `EndgateRootSnapshot`
- `RunSnapshot`
- `ObjectiveSnapshot`
- `WavePlanSnapshot`
- `WaveArchetypeMixSnapshot`
- `WaveIntelSnapshot`
- `FirstSessionGuidanceSnapshot`
- `FirstSessionMilestoneSnapshot`
- `ProgressionSnapshot`
- `EndgateMapSnapshot`
- `CharacterSnapshot`
- `EnemyArchetypeSnapshot`
- `EnemyRenderBatchSnapshot`
- `EnemyCorpseBatchSnapshot`
- `CombatEventBatchSnapshot`

`WavePlanSnapshot` is reveal-controlled by scouting level. At low intel it
projects an empty `scriptId`, public hidden/unknown labels, zero exact numeric
fields, and no weighted mix. At full intel it projects the next, active, or
retry script id, display label, summary, pressure pattern, target policy, enemy
budget, spawn cadence, clear/failure rewards, and weighted archetype mix. The
host owns those values; the client does not author wave scripts or choose enemy
behavior.

`WaveIntelSnapshot` is the player-facing scouting contract. It projects
`intelLevel`, scouting `source`, `confidence`, public summary, known pressure
and target fields, approximate budget/cadence/reward bands, archetype hints,
known spawn arcs, hidden field names, whether more can be revealed, and the next
scouting hint. Active enemies still reveal their archetype bytes as they spawn
during combat.

`FirstSessionGuidanceSnapshot` is the server-owned first-session loop contract.
It projects a stable guidance step id, label, detail, primary map mode, primary
command id, target kind, focus point, and milestone completion rows. The client
renders it as an affordance and proof surface; it does not decide tutorial
state, retry state, or spending order locally.

`EndgateMapSnapshot` carries `MapScaleSnapshot`, which is the live meter-scale
contract for the Endgate renderer: 1 gameplay meter per coordinate unit, 1m
build cells, 32m village zones, a 3 x 3 starting owned village, a 9 x 7 starting
active zone envelope, and separate readable sprite/ring dimensions for
characters and walls. The server owns those values; the renderer consumes them
as an adapter contract instead of hard-coding a debug canvas scale.

`ProgressionSnapshot` also carries `VillageExtentSnapshot`,
`VillageZoneSnapshot`, `BuildingDefinitionSnapshot`, `BuildingSnapshot`, and
`WallPieceSnapshot` rows. The
host owns which square parcels are owned,
available, or locked; the client renders them and sends `village.zone.purchase`
when the player buys an adjacent parcel between waves. Non-wall building
placement uses server-projected `zone_slot` rows inside owned parcels, and
`building.place` validates energy, placement domain, slot class, rotation,
terrain blockers, footprint bounds, owned cells, and occupancy before mutating
the village. `building.relocate` and `building.rotate` consume server-owned
energy costs and update the authoritative building snapshot. Wall cells are
freeform 1x1 `wall_cell` build-cell buildings; `building.wall_chain.place`
places one contiguous run, `building.wall_gate.toggle` flips a cell between
blocking wall and passable gate, `building.wall_tower.place` places a tower on
or beside a wall cell, and `building.wall_run.upgrade` /
`building.wall_run.demolish` tier or salvage the connected run that contains the
target cell. Each validates cell ownership, run connectivity, energy, and phase
before creating or mutating wall, gate, or tower bodies.
`building.upgrade`, `building.repair`, and `building.demolish` mutate selected
structures only through the host. Wall rendering consumes server-projected
`WallPieceSnapshot` rows keyed by `cellId` (`wall:x:y`), each carrying the grid
coordinate, autotile connection mask, tier, HP, blocker strength, gate
passability, and breach state.

`CharacterSnapshot` projects the whole current roster. Each row includes
position and target, locked/alive/downed state, hit points, damage, range,
attack cadence, movement speed, armor, target priority, special skill identity,
special power, special cooldown/remaining cooldown, revive cost, whether the
main-character downed penalty is active, kill count, current target, and the
server-authored upgrade affordances. The client can display and request these
upgrades, but the host alone mutates the values.

`EnemyRenderBatchSnapshot` is the live body batch. It includes positions, hit
point ratios, flow bands, threat flags, and an archetype byte per active goblin
identifying its template. `EnemyArchetypeSnapshot` separately projects the five
server-authored goblin templates used by wave scripts: `GoblinScout`,
`GoblinWarrior`, `GoblinRaider`, `GoblinSapper`, and `GoblinChief`.
`EnemyCorpseBatchSnapshot`
and `CombatEventBatchSnapshot` are separate bounded visual-feedback batches.
They project death marks, fade age, attack lines, base/building damage flashes,
special pulses, and healing pulses from server facts. The browser may draw
those effects, but it does not invent combat outcomes or retain gameplay
authority for them.

That projected contract is what the client renders and what the benchmark
surface measures. It is also the place where host grid cells become frontend-
friendly `worldX` and `worldY` floats for edge arc telegraphs, village zones,
and character positions.

## Current limitations

- The whole roster is SECS entities on the `CharacterRoot` scope; each
  character's hot per-tick values (position, cooldowns) live in a thin
  `CharacterRuntime` wrapper, and its progression is persisted by the explicit
  host save payload.
- Save/load is planning-state durability. Mid-wave active enemy buffers are not
  persisted until a future replay/checkpoint design defines the full hot-state
  contract.
- Endgate schema-owned generated code is compiler output. Hand-maintained
  `Generated/` files are limited to explicit non-schema host/catalog helpers.
- The browser receives projected snapshots; it does not read `.secs` content
  directly.
