Engine Events & Captured Values
A reference for the event object the Artemis: Cosmos engine passes to
cosmos_event_handler, the fields each kind of event carries, and engine values
that are not in shipData (learned from the source or by capturing a running
mission). This is a living document — add to it whenever a field meaning or an
engine value is confirmed.
How a fact got here
Each entry is tagged with how it was confirmed:
- (code) — read directly from the
sbs_utilssource or the engine API. - (capture) — observed by running the
data_capturemission in the real engine and logging the event/data (see Capturing values below). - (TBD) — not yet confirmed; the expected location is noted.
The event object
The engine drives everything through one handler:
def cosmos_event_handler(sim, event):
...
event carries these fields (code):
| Field | Type | Meaning |
|---|---|---|
client_id |
int | Console the event came from. 0 = server; >0 = a connected console. |
tag |
str | Event kind — the dispatcher keys on this (e.g. "gui_message", "damage"). |
sub_tag |
str | Secondary key — widget tag, damaged-system index, collision kind, … |
origin_id |
int | The acting / source object. |
selected_id |
int | The target / selected object. |
parent_id |
int | Parent object (e.g. the ship a launched weapon came from). |
value_tag |
str | A string value (dropdown selection, typed text, …). |
extra_tag |
str | Extra string slot. |
extra_extra_tag |
str | Extra string slot (e.g. weapon kind on a launch). |
sub_float |
float | A numeric value (slider value, dropdown index, …). |
source_point |
vec3 | A 3D point (e.g. the mesh point an internal hit landed on). |
event_time |
float | Engine time of the event. |
client_id == 0 is the server; client IDs > 0 are player consoles.
Routed context variables
Routes (//… labels) translate event fields into named variables before the
route body runs. Confirmed mappings:
//damage/* (code — procedural/routes.py)
| Variable | Source |
|---|---|
DAMAGE_SOURCE_ID / DAMAGE_ORIGIN_ID |
event.origin_id |
DAMAGE_TARGET_ID / DAMAGE_SELECTED_ID |
event.selected_id |
DAMAGE_PARENT_ID |
event.parent_id |
EVENT |
the whole event object |
Damage route variants: //damage/object (any hit), //damage/destroy,
//damage/killed, //damage/internal (player system damage), //damage/heat.
The damage amount is in sub_float (capture)
A //damage/object capture (capture_damage.json) confirms the per-hit fields:
EVENT field |
Carries |
|---|---|
sub_float |
the damage amount delivered by this hit (raw weapon damage, e.g. a beam shot 5.5) |
sub_tag |
weapon kind: beam, drone, destroyed (the fatal hit) |
origin_id |
source ship |
selected_id |
target ship |
value_tag |
source ship's side (e.g. tsn) |
extra_tag |
source ship's name |
event_time |
engine time of the hit |
So to read actual delivered damage, listen on //damage/object and use
EVENT.sub_float (+ EVENT.sub_tag for the weapon). The mock now emits both
too (see the combat-events table below), so //damage logic behaves the same in
the dev runner.
GUI events (code / cosmos_dev confirmed)
All browser/console widget interactions arrive as tag = "gui_message". The
widget tag is a string (page.get_tag() → str(int)).
| Widget | sub_tag |
value_tag |
sub_float |
|---|---|---|---|
| Button / Checkbox | widget tag | — | — |
| Dropdown | widget tag | selected string | selected index |
| Slider | widget tag | — | raw value |
Icon (click_tag) |
the click_tag string | — | — |
| Text input | widget tag | cumulative string | — |
Browser type values seen by the dev runner: "gui_message" (click/activation),
"change" (value change), "submit" (typein Enter).
Damage / combat events (code — emitted by cosmos_dev/mock/sbs.py)
The mock emits these to mirror the engine so handlerhooks routes them. Tuple
order is (tag, sub_tag, origin_id, selected_id[, parent_id][, {extra fields}]).
tag |
sub_tag |
origin → selected | Fires |
|---|---|---|---|
damage |
weapon kind | source → target | //damage/object (non-fatal hit); {"sub_float": amount} |
damage |
"destroyed" |
source → target | //damage/destroy + object removal; {"sub_float": amount} |
npc_killed |
"" |
target → target | //damage/killed (NPC ship) |
station_killed |
"" |
target → target | //damage/killed (station) |
player_internal_damage |
"" |
target → source | //damage/internal; carries sub_float (system/amount) + source_point (mesh hit point) |
heat_critical_damage |
str(system_index) |
ship → ship | //damage/heat (engineering overheat, fired repeatedly while a system is at full heat - see note) |
ship_launches_drone |
"" |
source → target | //launch/drone; extra_extra_tag = "drone" |
player_launches_missile |
"" |
source → target | //launch/missile; extra_extra_tag = kind (Homing / Nuke / EMP / Mine) |
*_collision_start / *_collision_end |
kind | terrain→ship (1) · ship↔ship (both) | //collision/* |
Collision: passive vs interactive (data-driven)
For a ship-vs-terrain contact the kind is decided by the terrain object's radii:
data_set interactionradius > 0 → interactive (pickups — a ship within it
triggers the collection route); else exclusion_radius > 0 → passive (solid:
asteroids, mines). interactionradius is a data_set value loaded from shipData by art
id (not a space_object attribute); a pickup needs it set or //collision/interactive
never fires. A terrain contact emits one event — origin = terrain,
selected = ship (so COLLISION_ORIGIN_ID is the pickup) — and no _end if the
object is deleted on the start (pickup collected). Ship-vs-ship is interactive and
emits both id orderings (each sees itself as origin).
Damage events carry amount + weapon kind
Each damage event ends with {"sub_float": amount} (raw hit; 0.0 for EMP) and
sets sub_tag to the weapon kind (beam, drone, torpedo type, collision) —
except the fatal hit, which keeps sub_tag = "destroyed". The runner unpacks the
trailing dict onto the event, so EVENT.sub_float / EVENT.sub_tag read the same in
the mock as in the engine.
Heat is engineering overpower, not combat
system_cur_heat (per SHPSYS 0..3, 0..1) rises when engineering pushes a powered
subsystem above 100% (eng_control_value, 1.0 = 100%, up to 3.0 = 300%); the
overpower is summed onto the system it feeds (eng_control_type_index). Coolant
controls heat (system_coolant_used) — it is the only heat sink. At full heat
the mock fires heat_critical_damage → //damage/heat repeatedly (_physics_heat);
it does not write system_damage — the mission decides the consequence
(LegendaryMissions damages grid items and derives system_damage /
system_max_damage from grid-item counts). NPCs set no eng controls, so they never
overheat. Calibrated from capture_heat.json (below).
Captured engine values
Combat-relevant values that are not in shipData (the engine sets them at
runtime) or that needed measuring. Unless noted, captured at DIFFICULTY 5 —
several scale with difficulty (1–11), so re-capture at other difficulties to map
the scaling.
Movement (capture — capture_speed.json)
Engine velocity = cur_speed × 36 units/s. The throttle → cur_speed mapping:
cur_speed at full |
units/s | Notes | |
|---|---|---|---|
NPC (throttle 1.0) |
1.0 × speed_coeff |
36 × speed_coeff | scales per hull via speed_coeff |
Player impulse (playerThrottle 1.0) |
5.0 |
180 | hull-independent (speed_coeff not applied) |
Player warp (playerThrottle 3.0) |
30.0 |
1080 | 180 + (pt−1) × 450 |
Acceleration is a first-order lag (cur_speed approaches its target geometrically),
time constant ≈ 0.8 s NPC / 1.7 s player.
Hull & death (capture — capture_battle_matrix.json)
- NPC ship
system_max_damage[i] = hullpointsper SHPSYS (e.g.tsn_light_cruiserhp 3 →[3,3,3,3];tsn_battle_cruiserhp 4 →[4,4,4,4]). Death = all four systems maxed. - Stations use
armor(=hullpoints); ships have no armor.
Time-to-kill reference (DIFFICULTY 5): cruiser duels resolve in ~40–70 s (e.g.
tsn_battle_cruiser dies ~48 s, tsn_light_cruiser ~60 s, skaraan_executor
~67 s); players beat a single enemy in ~37–39 s; stations survive a lone attacker
for the full 2 min.
The mock runs a bit faster (~35–45 s duels): it's a stationary face-off where every
beam stays in arc, while the engine maneuvers and misses. So the mock can't
reproduce exact TTK — tests/test_mock_combat_ttk.py guards the ballpark
instead (duels resolve in 15–110 s; a starbase is >2× tankier than a cruiser),
which catches gross calibration drift without over-fitting to the idealization.
Shields (capture)
- Per-facing, not pooled: a fixed attacker drains only the facing toward it, then overflow hits the hull — the other facings stay up (ships die with shields on their far side).
- Regen: each facing recovers at
repair_rate_shields ÷ shield_countper second. Confirmed by the regen bench (ships alone, shields at 10%): player1.0/2 → 0.5/s, NPCs0.1/2 → 0.05/s— and Kralien / Torgoth / Skaraan all regen at the same 0.05/s (no per-hull difference). Rates: player1.0, NPC0.1(code).
Beams (capture — capture_damage.json, sub_float)
beamDamagein the data_set is the per-beam coefficient (1.0for base hulls), not the per-shot damage. Per-shot =set_beam_damagesbase × coeff.- Per-shot full-health base (coeff 1.0 hulls). Use early-fight / max hits, not the median — the engine's death spiral lowers a damaged ship's logged beam damage, so medians under-report the base.
| Firer | per-shot base | Notes |
|---|---|---|
| NPC ship | ≈ 5.5 | base × coeff(1.0) |
| Player | ≈ 8.5 | players hit ~55% harder than NPCs |
| Skaraan (elite) NPC | ≈ 16.5 | shipData damage_coeff ≈ 3.0 |
beamCycleTime ≈ 6 s,beamArcWidth = 144°,beamRange1000–1300 (code/capture).- Mock: when a mission calls
set_beam_damages(LegendaryMissions does) the mock honors it. Otherwise it falls back tocoeff × _BEAM_LOAD_BASE = 6for every firer — so NPC beams are about right (6 vs ~5.5) but player beams are ~30% low (6 vs ~8.5). Splitting the fallback to player 8.5 / NPC 5.5 would close it (the values are difficulty-independent, so safe to pin).
Difficulty does NOT scale per-ship combat stats (captures at DIFFICULTY 1, 5, 11)
Running the battle matrix at difficulty 1, 5 and 11 shows the per-ship combat inputs are flat across all three:
- NPC beam (~5.5 full-health), player beam (~8.5), Skaraan beam (~16.5), drone (15)
- shields and
system_max_damage(hull) — identical per hull at every level
(An earlier read suggested player beam scaled with difficulty; that was an artifact of comparing fight medians, which the death spiral confounds. The full-health bases are difficulty-independent.)
So the mock needs no difficulty-aware combat scaling. Difficulty must affect other things (fleet size, AI behaviour, …) that this per-ship capture doesn't measure. NPC-vs-NPC outcomes still vary run to run — combat variance, not difficulty.
Drones (capture)
Drone-capable hulls carry drone_launch_timer in shipData — Torgoth and
Ximni only (e.g. torgoth_behemoth, xim_dreadnought). The engine sets the
rest at runtime:
| Field | Value (diff 5) | Source |
|---|---|---|
drone_damage |
15 | capture |
drone_launch_max_range |
7000 | capture |
drone_launch_timer |
20–60 (per hull) | shipData |
elite_drone_launcher |
unset (= 0) | capture — not the trigger; drone_damage/range being set is |
Drone damage is difficulty-independent (15 at diff 1, 5 and 11).
Engineering heat & coolant (capture — capture_heat.json)
Heat is engineering overpower, not combat (see the note above). Calibrated by driving
one eng_control_value feeding ship system 0 and logging system_cur_heat:
| Quantity | Value | Notes |
|---|---|---|
eng_control_value scale |
1.0 = 100%, up to 3.0 = 300% | 1.0 → no heat; >1.0 heats |
| Heat gain | 0.0094 /s per unit overpower (value − 1.0) |
linear across 100–300% (1.5→0.0047, 2.0→0.0094, 3.0→0.0188 /s) |
| Coolant | ~0.002 /s removed per system_coolant_used unit |
~10 units fully cancel 300%; coolant is the only heat sink |
| Passive decay | none | with no overpower and no coolant, heat holds flat |
Past 300% the engine's gain goes sub-linear (value 100 → 0.046/s, not the
0.93/s a linear law predicts), but the console can't exceed 300%, so the mock clamps
overpower at 2.0 (_HEAT_OVERPOWER_MAX). At full heat the system overheats and fires
//damage/heat; the mission applies the damage (the mock doesn't write system_damage).
Torpedoes (defined via torpedo_type() → engine shared strings)
- Player-exclusive — NPCs never fire torpedoes (they fire drones). Tube count
from
shipDatatubecount(code). - Torp definitions live in engine shared strings.
torpedo_type()(which the LM torpedo prefabs call fromstart_server) writes each torp's attributes to a shared string keyed by its name, e.g.Nuke→"...;warhead:blast;damage:5;blast_radius:1000;behavior:homing;lifetime:25;". The mock reads these (_torp_attrsparses the shared string) so it honors any mission's torp definitions — it's not tied to LegendaryMissions — and falls back to LM-equivalent per-kind defaults only when a torp isn't registered. A malformed torp string degrades gracefully (bad numeric → default, unknown warhead → single hit, unknown behaviour → homing) and warns once;sbs.torp_validate(key)returns a list of problems for a definition (empty = clean) for missions/tools to check. warhead:standard= single-target hull;blast= a lingering, growing-ring AoE (see below);reduce_shields= halve the target(s)' shields.behaviour:homingormine.blast_radiusdefault 1000,lifetime25 s (the blast lingers for the torp'slifetime).- Flight (mock). Every torp launches as a flying projectile aimed at the
weapon-selected target (
weapon_target_UID); with no selection it flies straight along the firer's heading. A homing (warhead) torp re-homes toward its target each tick and, if that target is destroyed, re-acquires the nearest object (_nearest_hittable) — "if the target is gone, find the closest"; with no original selection it does not re-acquire (stays straight). It detonates on contact and is culled if it flies its launch range without connecting. A mine ignores the target: it drops out the stern (opposite the firer's heading), coasts inert to its distance (max_range), then stops and deploys as a stationary armed proximity mine that sticks around (detonates when a ship enters the trigger radius, or until mine-life expires) — mines aren't armed until they stop.
| Type | warhead | damage | radius | behaviour |
|---|---|---|---|---|
| Homing | standard | 35 (single hit) | – | homing |
| EMP | blast + reduce_shields | 0 hull, halves shields | 1000 | homing |
| Nuke | blast | 5 per ripple → ~120 at centre | 1000 | homing |
| Mine | blast | 5 per ripple → ~120 at centre | 1000 | mine (stern-drop, deploys at distance, proximity) |
| Tag | standard | 0 | – | homing |
The blast warhead is a growing ring, not a single hit
A blast torp doesn't deal its damage at once. It detonates into a ring that
grows from 0 to blast_radius over the lifetime, and each ripple deals
damage (5) to whatever is currently inside the ring. So a direct hit is in
the ring from the first ripple and accumulates all of them (~120, matching the
//damage sub_float building up to ~120 in the capture); something off-centre
is reached by the ring later and takes fewer ripples → less. Because the ring grows
linearly, the total on a stationary target equals a linear distance falloff
(~120 × (1 − d/radius)) — but the ring also catches ships that drift in over
the lifetime, which a single hit wouldn't. The mock models this in _physics_blasts
(_register_blast on detonation); EMP is a one-shot AoE shield-halve (_apply_emp);
a Mine drops out the stern, coasts to its distance, then deploys as a stationary
armed proximity mine (behaviour: mine; see Flight above).
The data_capture run measured the engine's built-in torps (Homing 35, Nuke
building to ~120, EMP 0 hull) — its custom flow skipped start_server — but the
net values agree with the LM definitions interpreted as per-ripple accumulation.
Capturing values
The data_capture dev mission (in data/missions/data_capture) runs scenarios in
the real engine and dumps JSON for calibration:
| Map | Output | Captures |
|---|---|---|
| Data Capture | capture_battle.json, capture_spawn.json |
1v1 starting data_set + fight log |
| Speed Capture | capture_speed.json |
impulse/warp speed per hull |
| Battle Matrix | capture_battle_matrix.json, capture_damage.json |
NPC/player/station/drone fights + per-hit //damage events |
| Torpedo Capture | capture_torpedo.json |
player torpedo damage + AoE ripple |
Every capture is tagged with DIFFICULTY (forced in the map body — start_server
overwrites a top-level value). Change CAPTURE_DIFFICULTY to capture another level.