Map Editor Documentation

Engine Hooks & Event Bus

How 3rd-party developers can hook into the deterministic simulation, listen to combat/VFX events, and extend game engine logic.

Page 15 of 17

Shards of Stone is architected around a strict boundary between a deterministic, lockstep simulation (running at a fixed 20 ticks per second) and an asynchronous presentation layer (rendering at 60+ frames per second via Three.js and HTML5 Canvas).

Third-party developers, custom map authors, and modders can hook into this architecture at three distinct levels:

  1. TriggerScript AST: Authored inside The Forge map editor or compiled from JSON without writing code.
  2. Native EventBus Listeners: Subscribing to canonical gameplay, combat, spell, economy, and victory events.
  3. Custom ECS Systems: Registering custom System classes directly into the simulation's World loop.

This guide details the decoupled event architecture, provides the complete catalog of 30+ canonical event hooks with exact TypeScript payload schemas, and outlines the determinism rules required to prevent multiplayer desyncs.


Architecture: The Decoupled Event Pipeline

The engine separates simulation authority from presentation side-effects. In standard browser play, the simulation can run on a background Web Worker (SimWorker.ts) or synchronously in single-player tests. The main thread hosts the React UI, HUD components, audio playback (SoundManager), and 2D/3D renderers (World3DRenderer, Model3DRenderer, CombatVFXRenderer).

flowchart TB
    subgraph Sim["Deterministic Simulation Authority (20 Ticks/Sec)"]
        TickLoop["GameLoop (dt = 0.05s)"]
        World["ECS World"]
        SimSystems["Systems (Combat, Spells, Build, AI, Triggers)"]
        SimBus["Simulation EventBus"]
        
        TickLoop --> World
        World --> SimSystems
        SimSystems -->|emit| SimBus
    end
 
    subgraph Bridge["Cross-Boundary EventBridge"]
        SimBus -->|Whitelisted sim->main| EB["EventBridge (__bridged: true)"]
        EB -->|Whitelisted main->sim| SimBus
    end
 
    subgraph Main["Main Presentation Thread (60+ FPS Interpolated)"]
        MainBus["Main Thread EventBus"]
        Renderer["3D/2D Renderers (Three.js / Canvas)"]
        Audio["SoundManager / VoiceLineSystem"]
        HUD["React HUD / Victory Banners"]
        UserInput["Player Input (Spell targeting, Build clicks)"]
 
        EB -->|Re-emit| MainBus
        MainBus --> Renderer
        MainBus --> Audio
        MainBus --> HUD
        UserInput -->|emit| MainBus
        MainBus -->|User Intent| EB
    end

The Per-Match EventBus (src/game/core/EventBus.ts)

Every match instantiates a dedicated EventBus. The engine purposefully avoids a persistent global singleton:

  • Memory Leak Defense: When a match concludes, game.destroy() invokes eventBus.clear(). This nulls the internal listener map, severing any closures that forgot to call .off().
  • Live Binding: replaceActiveEventBus() installs a freshly allocated bus at the start of every match. The module-level export let eventBus updates in-place, allowing ES module consumers to retain live bindings.
  • Directional Isolation (EventBridge.ts): In Web Worker mode, both the worker and the main thread maintain separate EventBus instances. The EventBridge routes only whitelisted messages across postMessage(), tagging each serialized payload with __bridged: true to eliminate infinite echo loops.
import { eventBus } from '@/game/core/EventBus';
 
// Registering a listener
function onMeleeHit(data: any) {
  console.log('Melee strike landed:', data);
}
 
eventBus.on('melee-attack-hit', onMeleeHit);
 
// Clean up when dismantling your module or system
eventBus.off('melee-attack-hit', onMeleeHit);

Canonical Event Catalog

Below is the exhaustive catalog of canonical engine hooks. All payload interfaces are guaranteed by the engine's serialization contracts and are safe for structured cloning across thread boundaries.

1. Simulation Lifecycle Hooks

Lifecycle events govern match initialization, entity birth/destruction, construction milestones, and victory declaration.

Event NameDirectionPrimary ConsumersDescription
mapInitSim internalTriggerSystemEvaluates map-initialization variables and tick-0 scenario setup.
onMatchStartSim $\rightarrow$ MainHUD, Camera, MusicFired when map placement finishes and players gain control.
entity-diedSim $\rightarrow$ MainDeathSystem, Ragdoll, VFXEmitted whenever any unit, building, or creep dies.
unit-trainedSim $\rightarrow$ MainProductionSystem, Audio, HUDEmitted when a training queue item completes and spawns.
building-completeSim $\rightarrow$ MainBuildSystem, Minimap, AudioFired when a building site reaches 100% build progress.
building-upgradedSim $\rightarrow$ MainBuildingUpgradeSystem, HUDFired when a building finishes upgrading to a higher tier.
upgrade-completeSim $\rightarrow$ MainResearchSystem, Tech TreeFired when a blacksmith or library finishes researching a tech.
game-overSim $\rightarrow$ MainVictory Screen, Result LedgerConcludes the match with final winner and end state.

entity-died Payload Schema (DeathPayload)

The death payload carries complete entity metadata so visual renderers and ragdoll managers never need to call world.getEntity(id) across thread boundaries:

export interface DeathPayload {
  readonly entityId: number;
  /** Owning player index (0..7), or -1 for neutral critters. */
  readonly playerId: number;
  readonly isBuilding: boolean;
  readonly isFlying: boolean;
  readonly isNaval: boolean;
  /** True for defensive walls and palisades. */
  readonly isWall: boolean;
  /** True for destructible creep camps and spawn nests. */
  readonly isCreepSpawner: boolean;
  readonly pixelX?: number;
  readonly pixelY?: number;
  readonly gridX?: number;
  readonly gridY?: number;
  /** Footprint dimensions in tiles (buildings only). */
  readonly gridW?: number;
  readonly gridH?: number;
  readonly unitTypeId?: string;
  readonly buildingTypeId?: string;
  readonly buildingRole?: string;
  readonly buildProgress?: number;
  readonly buildingComplete?: boolean;
  readonly wallTier?: 1 | 2 | 3;
  /** Entity ID of the killer; omitted for decay or scripted suicide. */
  readonly killerEntityId?: number;
  /** 8-way directional heading (0..7) at the exact moment of death. */
  readonly direction?: Direction;
}

Other Lifecycle Payload Schemas

// 'unit-trained'
export interface UnitTrainedPayload {
  readonly unitType: string;
  readonly playerId: number;
  readonly entityId?: number;
}
 
// 'building-complete'
export interface BuildingCompletePayload {
  readonly entityId: number;
  readonly buildingType: string;
  readonly playerId: number;
}
 
// 'building-upgraded'
export interface BuildingUpgradedPayload {
  readonly entityId: number;
  readonly oldType: string;
  readonly newType: string;
  readonly playerId: number;
}
 
// 'upgrade-complete'
export interface UpgradeCompletePayload {
  readonly upgradeId: string;
  readonly playerId: number;
}
 
// 'game-over'
export interface GameOverPayload {
  readonly winner: number; // Winning team or player ID (-1 for draw)
  readonly state: GameState; // GameState.VICTORY or GameState.DEFEAT
}

2. Combat & Damage Hooks

Combat events trigger unit hit reactions, projectile paths, area-of-effect rings, and screen trauma.

Event NameDirectionDescription
attack-startSim $\rightarrow$ MainAuthoritative windup start; contains synchronized animation cues.
melee-attack-hitSim $\rightarrow$ MainAuthoritative melee damage application and impact coordinates.
projectile-resolvedSim $\rightarrow$ MainProjectile hits target or fizzles at ground point.
aoe-impactSim $\rightarrow$ MainArea damage/heal detonation ring; carries spell ID and chain bounces.
cone-blast-vfxSim $\rightarrow$ MainDirectional spray/blast muzzle (Steam Tank shot, dragon breath).
crit-landedSim $\rightarrow$ MainCritical hit event; triggers floating gold text and sparkle particles.
entity-attackedSim $\rightarrow$ Sim/MainHigh-level damage notification driving worker retargeting and alarms.

Combat Payload Schemas

import type { DamageType } from '@/game/data/constants';
import type { AnimationCueDef } from '@/game/data/animationCues';
 
// 'attack-start'
export interface AttackStartEvent {
  readonly attackerEntityId: number;
  readonly targetEntityId: number;
  readonly attackKind: 'melee' | 'ranged';
  readonly unitTypeId?: string;
  readonly projectileType?: string;
  readonly sourcePixelX: number;
  readonly sourcePixelY: number;
  readonly targetPixelX: number;
  readonly targetPixelY: number;
  readonly cues: readonly AnimationCueDef[];
}
 
// 'melee-attack-hit'
export interface MeleeAttackHitPayload {
  readonly attackerEntityId: number;
  readonly targetEntityId: number;
  readonly attackerPixelX: number;
  readonly attackerPixelY: number;
  readonly targetPixelX: number;
  readonly targetPixelY: number;
  readonly damage: number;
  readonly unitTypeId?: string;
  readonly direction: number;
  readonly damageType: DamageType;
  readonly bonusElement: string | null;
  readonly isCrit: boolean;
}
 
// 'projectile-resolved'
export interface ProjectileResolvedPayload {
  readonly targetId: number;
  readonly damage: number;
  readonly pixelX: number;
  readonly pixelY: number;
  readonly projectileType: string;
  readonly damageType: DamageType;
  readonly bonusElement: string | null;
  readonly isCrit: boolean;
  readonly aoeRadius?: number;
  readonly sourceUnitTypeId?: string;
}
 
// 'aoe-impact'
export interface AoeImpactPayload {
  readonly pixelX: number;
  readonly pixelY: number;
  readonly radius: number;
  readonly color: string;
  readonly spellId?: string;
  /** Ordered list of entity targets for multi-hop chain abilities. */
  readonly chainHops?: Array<{ entityId: number; x: number; y: number }>;
}
 
// 'cone-blast-vfx'
export interface ConeBlastPayload {
  readonly entityId: number;
  readonly sourcePixelX: number;
  readonly sourcePixelY: number;
  readonly angle: number;      // Radians
  readonly halfAngle: number;  // Radians
  readonly lengthPx: number;
  readonly color: string;
}
 
// 'crit-landed'
export interface CritLandedPayload {
  readonly attackerEntityId: number;
  readonly targetEntityId: number;
  readonly pixelX: number;
  readonly pixelY: number;
  readonly damage: number;
  readonly isMelee: boolean;
}
 
// 'entity-attacked'
export interface EntityAttackedPayload {
  readonly targetEntityId: number;
  readonly attackerEntityId: number;
  readonly targetPlayerId: number;
  readonly attackerPlayerId: number;
  readonly damage?: number;
}

3. Spells & Abilities Hooks

Spell events handle cast bars, channeling, invocation failures, and special visual effects.

Event NameDirectionDescription
cast-startSim $\rightarrow$ MainSpell channeling began; drives unit cast bars and windup glows.
cast-completeSim $\rightarrow$ MainChanneling completed successfully; spell effects execute.
cast-cancelSim $\rightarrow$ MainChanneling interrupted by stun, silence, movement, or death.
spell-castSim $\rightarrow$ MainTriggered when active spell effects resolve into the world.
heal-vfxSim $\rightarrow$ MainGreen restorative sparkle burst over healed target.
polymorph-vfxSim $\rightarrow$ MainSmoke poof and silhouette transformation into a critter.
raise-dead-vfxSim $\rightarrow$ MainNecromantic summoning ring emerging from consumed corpses.
summon-vfxSim $\rightarrow$ MainPortal or glyph flash when conjuring extra units.
spell-failedSim $\rightarrow$ MainClient feedback toast when cast is denied (mana, cooldown, range).

Spells Payload Schemas

// 'cast-start'
export interface CastStartEvent {
  readonly entityId: number;
  readonly spellId: string;
  readonly totalTicks: number;
  readonly castAnimSlot: 1 | 2 | 3;
  readonly sourcePixelX: number;
  readonly sourcePixelY: number;
  readonly targetEntityId?: number;
  readonly targetPixelX?: number;
  readonly targetPixelY?: number;
  readonly targetGridX?: number;
  readonly targetGridY?: number;
  readonly cues: readonly AnimationCueDef[];
}
 
// 'cast-complete' & 'cast-cancel'
export interface CastCompletePayload {
  readonly entityId: number;
  readonly spellId: string;
}
 
export interface CastCancelPayload {
  readonly entityId: number;
  readonly spellId: string;
  readonly reason: 'stunned' | 'moved' | 'died' | 'silenced' | 'manual';
}
 
// 'spell-failed'
export interface SpellFailedPayload {
  readonly reason: 'not_enough_mana' | 'cooldown' | 'out_of_range' | 'invalid_target';
  readonly playerId: number;
  readonly casterEntityId: number;
}
 
// 'heal-vfx'
export interface HealVfxPayload {
  readonly targetEntityId: number;
  readonly amount: number;
  readonly pixelX: number;
  readonly pixelY: number;
}
 
// 'polymorph-vfx'
export interface PolymorphVfxPayload {
  readonly pixelX: number;
  readonly pixelY: number;
  readonly color: string;
  readonly spellId: string;
}
 
// 'raise-dead-vfx'
export interface RaiseDeadVfxPayload {
  readonly pixelX: number;
  readonly pixelY: number;
  readonly corpseCount: number;
}
 
// 'summon-vfx'
export interface SummonVfxPayload {
  readonly pixelX: number;
  readonly pixelY: number;
  readonly unitTypeId: string;
}

4. Items & Economy Hooks

Hooks associated with hero inventory, shops, resource drop-offs, and creep rewards.

Event NameDirectionDescription
item-purchasedSim $\rightarrow$ MainItem bought from a neutral or base shop.
item-equippedSim $\rightarrow$ MainItem shifted into an active equipment socket.
item-usedSim $\rightarrow$ MainActive consumable or artifact activated from inventory.
item-droppedSim $\rightarrow$ MainItem dropped onto terrain or floating on water.
item-soldSim $\rightarrow$ MainItem sold back to shopkeeper for gold refund.
resource-gatheredSim $\rightarrow$ MainWorker delivers gold, lumber, stone, or oil to a drop site.
creep-bountySim $\rightarrow$ MainNeutral creep slain; awards gold and triggers loot rolls.

Economy Payload Schemas

// 'item-purchased'
export interface ItemPurchasedPayload {
  readonly playerId: number;
  readonly shopEntityId: number;
  readonly heroEntityId: number;
  readonly itemId: string;
  readonly cost: number;
}
 
// 'item-equipped'
export interface ItemEquippedPayload {
  readonly heroEntityId: number;
  readonly itemId: string;
  readonly slotIndex: number;
  readonly statsDelta?: Record<string, number>;
}
 
// 'item-used'
export interface ItemUsedPayload {
  readonly heroEntityId: number;
  readonly playerId: number;
  readonly itemId: string;
  readonly slotIndex: number;
}
 
// 'item-dropped'
export interface ItemDroppedPayload {
  readonly heroEntityId: number;
  readonly itemId: string;
  readonly gridX: number;
  readonly gridY: number;
  readonly groundEntityId: number;
}
 
// 'item-sold'
export interface ItemSoldPayload {
  readonly heroEntityId: number;
  readonly playerId: number;
  readonly itemId: string;
  readonly goldRefund: number;
}
 
// 'resource-gathered'
export interface ResourceGatheredPayload {
  readonly resourceType: 'gold' | 'lumber' | 'stone' | 'oil';
  readonly amount: number;
  readonly playerId: number;
}
 
// 'creep-bounty'
export interface CreepBountyPayload {
  readonly killerPlayerId: number;
  readonly gold: number;
  readonly entityId: number;
  readonly pixelX: number;
  readonly pixelY: number;
}

5. Victory Conditions & Objectives Hooks

These events drive custom scenario victory criteria, banners, and the spectator HUD. To conserve bridge bandwidth, progress and countdown updates are throttled by the simulation to ~1 Hz (every 20 ticks) rather than broadcasting per tick.

Event NameDirectionCadenceDescription
wonder-startedSim $\rightarrow$ MainOnceGlobal announcement when Wonder foundation is laid.
wonder-completeSim $\rightarrow$ MainOnceWonder construction finishes; starts hold countdown.
wonder-countdownSim $\rightarrow$ Main~1 HzRemaining ticks on Wonder victory hold timer.
wonder-progressSim $\rightarrow$ Main~1 Hz / CrossConstruction progress percentage (quarter milestones).
king-slainSim $\rightarrow$ MainOnceRegicide king assassinated; triggers empire wipe.
relic-picked-upSim $\rightarrow$ MainTransitionHero retrieves a holy relic from ground/water.
relic-droppedSim $\rightarrow$ MainTransitionRelic dropped when carrier falls or bank collapses.
control-point-capturedSim $\rightarrow$ MainTransitionMonument or hill captured by a contending team.
score-standingsSim $\rightarrow$ Main~1 HzCurrent score standings under Score Victory rules.

Victory Payload Schemas

// 'wonder-started' & 'wonder-complete'
export interface WonderStartedPayload {
  readonly entityId: number;
  readonly playerId: number;
}
 
export interface WonderCompletePayload {
  readonly entityId: number;
  readonly playerId: number;
  readonly holdTicks: number;
}
 
// 'wonder-countdown'
export interface WonderCountdownPayload {
  readonly entityId: number;
  readonly playerId: number;
  readonly remainingTicks: number;
}
 
// 'wonder-progress'
export interface WonderProgressPayload {
  readonly entityId: number;
  readonly playerId: number;
  readonly gridX: number;
  readonly gridY: number;
  readonly percent: number;
  readonly milestone: boolean;
  readonly milestonePercent: number;
}
 
// 'king-slain'
export interface KingSlainEvent {
  readonly playerId: number;
  readonly teamId: number;
  readonly entityId: number;
}
 
// 'relic-picked-up' & 'relic-dropped'
export interface RelicPickedUpPayload {
  readonly relicEntityId: number;
  readonly index: number;
  readonly heroEntityId: number;
  readonly playerId: number;
}
 
export interface RelicDroppedPayload {
  readonly relicEntityId: number;
  readonly index: number;
  readonly gridX: number;
  readonly gridY: number;
  readonly playerId: number;
  readonly reason: 'carrier_lost' | 'bank_destroyed';
}
 
// 'control-point-captured'
export interface ControlPointCapturedPayload {
  readonly entityId: number;
  readonly playerId: number;
  readonly teamId: number;
}
 
// 'score-standings'
export interface ScoreStandingsPayload {
  readonly remainingTicks: number;
  readonly standings: Array<{
    readonly playerId: number;
    readonly teamId: number;
    readonly score: number;
  }>;
}

How 3rd-Party Developers Hook In

Developers have three distinct options depending on the scope of their extension.

Method 1: In-Game Scripting via TriggerScript AST

For custom maps, scenarios, and mods distributed via map files, modders use the Trigger AST. Triggers are authored as pure JSON nodes that compile into the map without executing raw JavaScript.

Triggers hook into simulation events using the events array:

{
  "id": "trig_boss_phase_2",
  "name": "Boss Enrage at 50% HP",
  "enabled": true,
  "events": [
    {
      "k": "unitAttacked",
      "unit": { "k": "entityByAid", "aid": "boss_dragon" }
    }
  ],
  "conditions": [
    {
      "k": "lt",
      "a": {
        "k": "unitProp",
        "unit": { "k": "entityByAid", "aid": "boss_dragon" },
        "prop": "hp"
      },
      "b": 2500
    },
    {
      "k": "eq",
      "a": { "k": "var", "name": "phase2_triggered" },
      "b": false
    }
  ],
  "actions": [
    { "k": "setVar", "name": "phase2_triggered", "val": true },
    {
      "k": "raiseEvent",
      "name": "boss-enraged",
      "data": { "bossAid": "boss_dragon" }
    },
    {
      "k": "order",
      "order": "attack_move",
      "units": { "k": "unitsWithTag", "tag": "minions" },
      "target": { "k": "regionCenter", "region": "player_base" }
    }
  ]
}

Key Trigger Actions for Inter-Trigger Communication:

  • raiseEvent: Fires a custom named event that other triggers can listen for via { "k": "customEvent", "name": "..." }.
  • runTrigger: Directly invokes another trigger, bypassing its event and condition guards.
  • enableTrigger / stopTrigger: Toggles trigger activation state dynamically at runtime.

Method 2: Native JavaScript/TypeScript Extension via EventBus

When developing engine plugins, external tools, web spectator overlays, or custom game modes bundled with the engine, you can attach listeners directly to eventBus:

import { eventBus } from '@/game/core/EventBus';
import type { DeathPayload } from '@/game/systems/deathPayload';
import type { AttackStartEvent } from '@/game/data/animationCues';
 
export class CombatTelemetryLogger {
  private boundOnDeath = (data: unknown) => this.onEntityDied(data as DeathPayload);
  private boundOnAttack = (data: unknown) => this.onAttackStart(data as AttackStartEvent);
 
  init(): void {
    eventBus.on('entity-died', this.boundOnDeath);
    eventBus.on('attack-start', this.boundOnAttack);
  }
 
  destroy(): void {
    eventBus.off('entity-died', this.boundOnDeath);
    eventBus.off('attack-start', this.boundOnAttack);
  }
 
  private onEntityDied(payload: DeathPayload): void {
    if (payload.isBuilding) {
      console.log(`Building ${payload.buildingTypeId} razed for player ${payload.playerId}`);
    }
  }
 
  private onAttackStart(event: AttackStartEvent): void {
    if (event.attackKind === 'ranged') {
      console.log(`Ranged attack from ${event.attackerEntityId} to ${event.targetEntityId}`);
    }
  }
}

[!WARNING] Always unregister listeners in destroy(). While EventBus.clear() flushes listeners when a match ends, long-running single-player dev sessions or hot-reloading will leak closures if unsubscriptions are skipped.


Method 3: Custom ECS Systems (../ecs/System)

For logic that must run continuously inside the simulation tick loop, developers subclass System and register it with world.addSystem().

Step 1: Subclass System

import { System } from '@/game/ecs/System';
import { eventBus } from '@/game/core/EventBus';
import type { Position } from '@/game/components/Position';
import type { Health } from '@/game/components/Health';
 
export class PoisonSwampSystem extends System {
  // Systems with lower priority numbers update earlier in the tick.
  // 50 runs after MovementSystem (20) but before CombatSystem (60).
  priority = 50;
 
  private tickAccumulator = 0;
 
  init(): void {
    // Systems can register event listeners during initialization
    console.log('[PoisonSwampSystem] Initialized in World');
  }
 
  update(dt: number): void {
    this.tickAccumulator++;
    // Run environmental check every 20 ticks (1 second)
    if (this.tickAccumulator % 20 !== 0) return;
 
    // DETERMINISM RULE: Always query entities using canonically sorted queries!
    const entities = this.world.getEntitiesWith('position', 'health', 'owner');
 
    for (const entity of entities) {
      // Never affect dead or flying entities
      if (entity.hasComponent('flying')) continue;
 
      const pos = entity.getComponent<Position>('position');
      const health = entity.getComponent<Health>('health');
 
      // Check if entity is standing on swamp terrain (assumes custom tile check)
      if (this.isSwampTile(pos.gridX, pos.gridY)) {
        const damage = 5;
        health.current = Math.max(1, health.current - damage);
 
        // Notify presentation layer of environmental damage tick
        eventBus.emit('combat-number', {
          entityId: entity.id,
          amount: damage,
          kind: 'poison',
          pixelX: pos.pixelX,
          pixelY: pos.pixelY,
        });
      }
    }
  }
 
  private isSwampTile(x: number, y: number): boolean {
    // Custom terrain inspection logic...
    return false;
  }
 
  destroy(): void {
    // Clean up timers or internal buffers
  }
}

Step 2: Register with the ECS World

import { world } from '@/game/ecs/WorldInstance';
import { PoisonSwampSystem } from './PoisonSwampSystem';
 
// Add the system with its defined priority
const swampSystem = new PoisonSwampSystem();
world.addSystem(swampSystem, swampSystem.priority);

Strict Determinism Rules for Sim-Side Callbacks

In a lockstep peer-to-peer or worker-authoritative architecture, every client must produce bit-for-bit identical state on every tick. Any divergence causes an immediate, unrecoverable desync.

Third-party developers writing simulation logic or listening to sim events MUST adhere to the following four rules:

1. Strictly Synchronous Execution

Sim-side callbacks and system updates must execute synchronously within the tick.

  • Never use async/await, Promise, setTimeout(), setInterval(), or requestAnimationFrame() inside simulation logic.
  • Never defer simulation state changes to the next microtask.
  • If an action takes time (such as a 3-second spell cast or a build queue), store a remainingTicks: number counter in an ECS component and decrement it synchronously each tick.

2. Bit-Exact & Deterministic Math

Different JavaScript engines (Google V8, Mozilla SpiderMonkey, Apple JavaScriptCore) implement transcendental math functions slightly differently.

  • Banned: Math.random(). Instead, use gameRandom.nextInt(min, max) or seed-based deterministic hashing (fnv1a).
  • Banned: Date.now() and performance.now(). The simulation's only clock is the integer tick count (world.ticks).
  • Banned: Math.sin(), Math.cos(), Math.tan(), Math.hypot(). Use integer lookup tables or bit-exact integer square roots (detHypot).
  • All floating-point operations should be bounded and truncated with Math.trunc() or rounded via integer math (Math.floor(x + 0.5)).

3. Canonical Entity Iteration Order

JavaScript Set and object key iteration order is insertion-order dependent. If Client A spawned a unit before a building, and Client B loaded the map in reverse order, iterating a raw Set will evaluate actions in different orders, causing desyncs when spatial budgets or targeting slots are contested.

// ❌ WRONG: Non-deterministic iteration order
const set = world.getComponentIndex('combat');
for (const entityId of set) {
  // Diverges across clients!
}
 
// ❌ WRONG: Using unsorted render queries in simulation
const entities = world.getEntitiesWithIntoUnsortedRenderOnly([], 'combat', 'position');
 
// ✅ CORRECT: getEntitiesWith is canonically sorted by entity.id (ascending)
const entities = world.getEntitiesWith('combat', 'position');
 
// ✅ CORRECT: Allocation-free sorted query for hot tick loops
const scratchBuffer: Entity[] = [];
world.getEntitiesWithIntoSorted(scratchBuffer, 'combat', 'position');
for (const entity of scratchBuffer) {
  // 100% deterministic on all machines!
}

4. Snapshot Independence in Loops

When modifying entities inside a loop, take a snapshot of the candidate list before mutating components or destroying entities:

// ✅ CORRECT: Snapshot array prevents mid-loop index corruption
const targets = world.getEntitiesWith('health');
for (const entity of targets) {
  if (shouldDie(entity)) {
    entity.destroy(); // Safe because 'targets' is a stable snapshot
  }
}

Previous: Custom Units & 3D Meshy Models. Next: Web APIs & Headless SDK.