People ask "how do I extend the engine?" and mean three different things. The honest answer is different for each.
| You want to… | How | Needs a fork of the engine? |
|---|---|---|
| React to things happening in a match, from a map | Trigger events | No |
| Tell the page around an embedded match that something happened | Raise custom event → sos:trigger-event | No |
| Run sandboxed JavaScript or TypeScript with supported game callbacks | Forge Scripts and the sos API | No |
| Add a native system or access unexposed engine internals | Edit the engine's source | Yes |
Map scripts use the bounded sandbox API. Registering a native system or accessing engine internals requires a fork or a checkout of the game's GNU AGPL v3 repository.
From a map: trigger events
Maps hook the engine through triggers. The events cover time, regions, unit deaths, training, attacks, projectile hits, hero levels, items, research, eliminations, chat, key presses, dialog buttons, variable changes, cutscenes, and custom events. Each is documented in the Trigger Reference.
Triggers talk to each other with custom events. In this script the first
trigger raises boss_enraged once, and the second listens for it:
{
"version": 1,
"vars": [{ "name": "enraged", "type": "bool" }],
"regions": [],
"triggers": [
{
"id": "boss_enrage",
"events": [{ "k": "unitAttacked", "filter": { "tags": ["boss"] } }],
"conditions": [
{ "k": "lt", "a": { "k": "unitProp", "unit": { "k": "eventUnit" }, "prop": "hpPercent" }, "b": 50 },
{ "k": "not", "a": { "k": "var", "name": "enraged" } }
],
"actions": [
{ "k": "setVar", "name": "enraged", "value": true },
{ "k": "raiseEvent", "name": "boss_enraged", "value": 1 }
]
},
{
"id": "boss_enraged_warning",
"events": [{ "k": "custom", "name": "boss_enraged" }],
"actions": [
{ "k": "showText", "players": null, "text": "The boss is enraged!", "durationTicks": 100 }
]
}
]
}A variable a trigger reads, like enraged, has to be declared in vars. The
custom event fires later in the same tick, and chains of events raising events
stop at 64 deep. Two related actions: runTrigger runs another trigger's actions
inline (optionally checking its conditions), and enableTrigger turns one on
or off.
From a page: sos:trigger-event
When a map is running inside an embed, every Raise custom event action is
also posted to the host page as a sos:trigger-event message with the event's
name, value, text and tick. That is the supported way for a map to tell the
outside world something happened (a boss died, a level was completed) without
any code. The message format and origin rules are in
Web APIs.
The embed cannot send events into a match. Inbound messages are limited to loading a map and pausing or resuming.
In a fork: the event bus
Everything from here on requires editing the engine source.
Each match creates its own EventBus (src/game/core/EventBus.ts), and
game.destroy() clears it, so listeners from one match do not leak into the
next. The module exports the live bus as eventBus.
import { eventBus } from '../core/EventBus';
const onDied = (data: unknown) => { /* … */ };
eventBus.on('entity-died', onDied);
// …
eventBus.off('entity-died', onDied);When the simulation runs in a Web Worker, the worker and the main thread each
have a bus. src/game/worker/EventBridge.ts forwards an allow-listed set of
events across and tags forwarded payloads so they are not echoed back. An event
you add in the simulation will not reach the main thread until it is on that
list.
Events that are emitted today
Event names are kebab-case. These are verified to be emitted by the engine; the file named is where to read the exact payload, which is the source of truth.
| Event | Emitted by |
|---|---|
entity-died | systems/DeathSystem.ts |
unit-trained | systems/ProductionSystem.ts |
building-complete | several |
building-upgraded | systems/BuildingUpgradeSystem.ts |
upgrade-complete | systems/ResearchSystem.ts |
game-over | several (victory resolution, stalemate, cheats) |
attack-start, melee-attack-hit, crit-landed | systems/CombatSystem.ts |
projectile-resolved | systems/ProjectileSystem.ts |
aoe-impact | several |
cone-blast-vfx | systems/ConeAttackSystem.ts |
entity-attacked | several |
cast-start, cast-complete, cast-cancel, spell-cast, spell-failed, polymorph-vfx | systems/SpellSystem.ts |
heal-vfx, item-used, item-purchased, item-equipped, item-dropped, item-sold | systems/ItemSystem.ts |
raise-dead-vfx | systems/CorpseHaulSystem.ts |
summon-vfx | systems/SummonLifetimeSystem.ts |
resource-gathered | systems/GatherSystem.ts |
creep-bounty | systems/CreepSpawnSystem.ts |
wonder-started, wonder-progress, wonder-complete, wonder-countdown | systems/WonderSystem.ts |
king-slain | systems/RegicideSystem.ts |
relic-picked-up, relic-dropped | systems/RelicSystem.ts |
control-point-captured | systems/ControlPointSystem.ts |
score-standings | systems/ScoreVictorySystem.ts |
combat-number | systems/combat/combatNumber.ts |
trigger-event | systems/TriggerSystem.ts (the Raise custom event action) |
Paths are under src/game/. There is no "match started" bus event; an
embedding page gets sos:match-start instead.
One bus event runs the other way: a trigger-event emitted from outside the
trigger system (without source: 'internal') is delivered to the map's
triggers as a custom event. A fork can use that to raise map events from
engine code.
In a fork: adding a system
Simulation logic lives in systems: classes extending System
(src/game/ecs/System.ts) with an update(dt) method, run every tick in
priority order.
import { System } from '../ecs/System';
export class SwampDamageSystem extends System {
update(): void {
// Sorted by entity id: identical order on every machine.
const entities = this.world.getEntitiesWith('position', 'health');
for (const entity of entities) {
// …
}
}
}Register it with world.addSystem(system, priority) (lower priorities run
first). The game registers systems in three places: src/game/Game.ts (the
main-thread simulation), src/game/worker/SystemRegistry.ts (the worker
simulation) and tools/ai_loop/HeadlessGame.ts, and
tools/ai_loop/systemRegistrationSync.test.ts (npm run test:registration)
fails if they drift apart. A system registered in only one of them runs in
one mode of the game and silently not in another.
Determinism rules for simulation code
Every player's machine runs the whole match and compares checksums. Code that produces a different result on one machine ends the match. The rules, which the codebase enforces with lint rules and tests:
- No
Math.random(). Use the seededgameRandom(src/game/core/GameRandom.ts). An ESLint rule rejectsMath.randomin simulation code. - No clocks.
Date.now()andperformance.now()differ on every machine. Time is the tick count. - No implementation-approximated maths.
Math.sin,cos,tan,atan,atan2,exp,log,pow,hypotand**may return a different last bit in different browsers. Use the helpers insrc/game/core/deterministicMath.ts(for exampledetHypot). Addition, multiplication, division andMath.sqrtare exact and safe. - Deterministic iteration.
world.getEntitiesWith(...)is sorted by entity id. Iterating aSetorMapin insertion order is not safe when insertion order can differ.getEntitiesWithIntoSortedis the allocation-free sorted form;getEntitiesWithIntoUnsortedRenderOnlyis, as its name says, for rendering only. - Synchronous. No
await, promises or timers in simulation code. Something that takes time is a counter decremented each tick. - Presentation stays out. Rendering, sound and UI may do anything, as long as nothing they compute flows back into simulation state.
Previous: Web APIs.