Map Editor Documentation

Engine Internals & Events

What you can hook from a map, what you can hook from a page embedding the game, and what needs a fork of the engine: the event bus, adding systems, and the determinism rules a fork must follow.

Advanced · Page 22 of 46

People ask "how do I extend the engine?" and mean three different things. The honest answer is different for each.

You want to…HowNeeds a fork of the engine?
React to things happening in a match, from a mapTrigger eventsNo
Tell the page around an embedded match that something happenedRaise custom event → sos:trigger-eventNo
Run sandboxed JavaScript or TypeScript with supported game callbacksForge Scripts and the sos APINo
Add a native system or access unexposed engine internalsEdit the engine's sourceYes

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.

EventEmitted by
entity-diedsystems/DeathSystem.ts
unit-trainedsystems/ProductionSystem.ts
building-completeseveral
building-upgradedsystems/BuildingUpgradeSystem.ts
upgrade-completesystems/ResearchSystem.ts
game-overseveral (victory resolution, stalemate, cheats)
attack-start, melee-attack-hit, crit-landedsystems/CombatSystem.ts
projectile-resolvedsystems/ProjectileSystem.ts
aoe-impactseveral
cone-blast-vfxsystems/ConeAttackSystem.ts
entity-attackedseveral
cast-start, cast-complete, cast-cancel, spell-cast, spell-failed, polymorph-vfxsystems/SpellSystem.ts
heal-vfx, item-used, item-purchased, item-equipped, item-dropped, item-soldsystems/ItemSystem.ts
raise-dead-vfxsystems/CorpseHaulSystem.ts
summon-vfxsystems/SummonLifetimeSystem.ts
resource-gatheredsystems/GatherSystem.ts
creep-bountysystems/CreepSpawnSystem.ts
wonder-started, wonder-progress, wonder-complete, wonder-countdownsystems/WonderSystem.ts
king-slainsystems/RegicideSystem.ts
relic-picked-up, relic-droppedsystems/RelicSystem.ts
control-point-capturedsystems/ControlPointSystem.ts
score-standingssystems/ScoreVictorySystem.ts
combat-numbersystems/combat/combatNumber.ts
trigger-eventsystems/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 seeded gameRandom (src/game/core/GameRandom.ts). An ESLint rule rejects Math.random in simulation code.
  • No clocks. Date.now() and performance.now() differ on every machine. Time is the tick count.
  • No implementation-approximated maths. Math.sin, cos, tan, atan, atan2, exp, log, pow, hypot and ** may return a different last bit in different browsers. Use the helpers in src/game/core/deterministicMath.ts (for example detHypot). Addition, multiplication, division and Math.sqrt are exact and safe.
  • Deterministic iteration. world.getEntitiesWith(...) is sorted by entity id. Iterating a Set or Map in insertion order is not safe when insertion order can differ. getEntitiesWithIntoSorted is the allocation-free sorted form; getEntitiesWithIntoUnsortedRenderOnly is, 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.