Comparison of Warcraft 3 scripting and modern web RTS triggers

From JASS to JSON: Why We Rebuilt Warcraft III's Scripting Engine for the Modern Web

World & Lore9 min read
ClaudeBuilt the thing
Adam SturrockDecided what mattered

In the summer of 2002, Blizzard released Warcraft III: Reign of Chaos. Packed inside that retail disc was the Warcraft III World Editor, a piece of software that accidentally altered the history of competitive video games. Over the following decade, custom map creators used its trigger editor to invent the multiplayer online battle arena with Defense of the Ancients, popularised tower defense as a standalone subgenre, and laid the mechanical seeds that would eventually blossom into auto battlers.

A parchment campaign map showing routes across rugged mountainous terrain

Yet beneath that explosion of community creativity sat an architectural house of cards: JASS, or Just Another Scripting Syntax. JASS was an imperative, interpreted language designed in the late 1990s. It was fast enough for a single CPU core, but it asked map authors to act as manual memory managers and fragile lockstep police.

When Adam and I set out to build a fully moddable real-time strategy game for modern browsers, we studied JASS with deep admiration and immediate dread. We had already spent weeks reading two decades-old map formats to import terrain from older titles. But running legacy procedural scripting inside modern web browsers does not just fail to scale; it breaks multiplayer lockstep determinism at the fundamental compiler level.

Here is why we abandoned procedural code for a closed, deterministic JSON Abstract Syntax Tree, and how that decision made browser RTS maps both leak-proof and AI-native.

The three architectural demons of JASS

To understand why web RTS modding requires a new foundation, you must examine the three structural flaws that haunted every custom map tournament in 2005.

1. The handle leak plague

In JASS, game entities (units, locations, groups, timers, and condition expressions) are represented as opaque integer handles pointing into a global engine table. The engine tracked these with an internal reference counter, but the scripting language required manual pointer destruction:

local location p = GetRectCenter(gg_rct_Spawn)
call CreateNUnitsAtLoc(5, 'ogbr', Player(1), p, 270.0)
// If you forget this exact line, 32 bytes leak permanently in memory:
call RemoveLocation(p)
set p = null

Notice the final two lines. Calling RemoveLocation freed the spatial coordinate inside the engine, while assigning null cleared the variable reference on the VM stack. If a map author omitted RemoveLocation, the handle slot remained occupied forever. If they omitted set p = null, the engine reference counter refused to recycle the slot.

In a custom Tower Defense map spawning 60 creeps every five seconds, a single missing cleanup line leaked thousands of handles per minute. After twenty minutes of play, the global handle table exceeded 300,000 entries. Pathfinding slowed down, garbage collection stalled, and the client crashed with a fatal out-of-memory error.

2. Out-of-sync desyncs

Multiplayer RTS games run on lockstep simulation: each client receives only player inputs, running an identical simulation frame-by-frame on local hardware. If two computers compute even one pixel of movement differently, the game states diverge and the match instantly drops into an Out-of-Sync error. We documented our lockstep test suite in how lockstep determinism is tested, and custom scripts are where that verification usually breaks.

JASS made desyncs horrifyingly easy to trigger:

  • Calling GetLocalPlayer() allowed authors to execute code only on one participant's computer (useful for showing private UI text). But if that block accidentally created a unit handle, read a random number, or touched pathfinding, the local simulation diverged and dropped all other players.
  • Unit group iteration in JASS was unsorted. Depending on memory layout and CPU cache order, two clients iterating through GetUnitsInRect could process targets in different sequences. If an action damaged the closest target first, differing iteration order meant different units survived.

3. The 50GB binary distribution barrier

A custom map was packaged inside an MPQ archive: a proprietary binary container holding raw terrain files, sound clips, and compiled bytecode. Playing a new map required launching a heavyweight native desktop client, sitting in a Battle.net chat room, and enduring a peer-to-peer download throttled to kilobytes per second. If the host disconnected, everyone was kicked back to the menu.

Why Lua and eval(js) break browser lockstep

When designing our browser engine, the obvious first thought was to embed a lightweight scripting language. WebAssembly can compile Lua in an afternoon, or we could have let modders write raw JavaScript callbacks executed inside Web Workers, following our approach to running simulation in a Web Worker.

Both paths lead to immediate multiplayer disaster on the open web.

The first issue is cross-engine floating-point variance. When three players join a match, one might be running Google Chrome on Windows (V8 engine), another Safari on an Apple M3 laptop (JavaScriptCore), and the third Firefox on Linux (SpiderMonkey).

Different JavaScript JIT compilers implement transcendental math functions differently:

// On Chrome V8 (x86-64):
Math.hypot(3.14159265, 2.71828182) // 4.154881079361849
 
// On a slightly older Safari JIT or ARM platform:
Math.hypot(3.14159265, 2.71828182) // 4.1548810793618485

That tiny discrepancy in the final bit of mantissa is invisible in a web page, but in an RTS collision system, it means a goblin clips a rock on one machine and slides past it on another. Five ticks later, the entire match has desynced.

The second issue is object iteration stability. In JavaScript, iterating over a Set or keys of a Record depends on insertion history. If client A creates unit 12 before unit 14, while client B receives the network packet in a split tick boundary, the iteration order diverges.

Finally, arbitrary code execution in the browser is a security hazard. Letting untrusted custom maps run arbitrary JavaScript means sandboxing fetch, localStorage, the DOM, and WebSockets. One sandbox bypass allows a map to steal cookies or mine cryptocurrency in the background.

The solution: a closed, deterministic JSON AST

Instead of executing procedural scripts, our game engine evaluates triggers as pure, declarative data. We defined a strict TypeScript schema called TriggerScript.

A trigger is not a script file. It is an abstract syntax tree stored as clean JSON:

{
  "id": "spawn_wave_periodic",
  "events": [
    { "k": "periodic", "everyTicks": 200 }
  ],
  "conditions": [
    { "k": "lt", "a": { "k": "var", "name": "wave" }, "b": { "k": "int", "v": 20 } }
  ],
  "actions": [
    {
      "k": "createUnits",
      "unitTypeId": "goblin_brawler",
      "count": { "k": "int", "v": 5 },
      "player": { "k": "int", "v": 1 },
      "at": { "k": "regionCenter", "region": "spawn" },
      "intoVar": "active_wave"
    },
    {
      "k": "order",
      "units": { "k": "var", "name": "active_wave" },
      "order": "attack_move",
      "to": { "k": "regionCenter", "region": "goal" }
    },
    { "k": "addVar", "name": "wave", "delta": { "k": "int", "v": 1 } }
  ]
}

This structure produces several radical benefits:

  1. Zero eval, zero bytecode: The simulation loop walks the node tree at each tick. The JSON is inert data, which means it cannot touch the browser DOM, read cookies, or execute infinite un-capped CPU loops.
  2. Canonical sorted evaluation: When an action queries units in a region, the engine returns a sorted array of numeric entity IDs. Every client on earth iterates the exact same entities in the exact same order.
  3. Integer and bit-exact spatial math: Spatial queries do not rely on hardware-dependent float routines. We use integer grid coordinates and a deterministic distance function (detHypot) that computes identical values on every CPU architecture.

The Dwarven Mountain Hold fortress viewed from high elevation with stone fortifications and workshop towers

Totality: the mathematical rule of zero throws

The most common bug in custom maps is the null pointer crash. A trigger fires when a projectile hits a hero, but the hero was killed by another unit in the preceding frame. In traditional languages, calling hero.getHealth() throws a null reference error, crashing the thread.

In our trigger engine, every single expression and action is total. In formal mathematics, a total function is defined for all possible inputs: it never enters an undefined state and never throws an exception.

Our evaluator enforces totality through four simple rules:

  • Missing entity safety: If a trigger requests properties for an entity that has been destroyed, the expression evaluator yields 0, false, or nullUnit. It never throws.
  • Division by zero safety: In the div and mod expression nodes, division by zero yields 0. It never produces NaN or Infinity, which would poison subsequent physics ticks.
  • Bounded execution: Loops are strictly capped at 10,000 iterations, and event cascades cannot exceed 64 deep. An accidental infinite loop written by an author simply stops executing and logs a diagnostic warning.
  • Zero allocations: Units in the engine are stored as flat numeric indices in Entity Component System arrays. There is no handle table, no RemoveLocation, and no manual pointer destruction. Ephemeral data is recycled through object pools.

Because the system cannot throw, an imperfect custom map continues to run smoothly rather than abruptly ending the match for all eight players.

AI as the new level designer

The transition from procedural code to structured JSON AST makes possible something Warcraft III modders could only dream of: reliable prompt-to-map generation with modern large language models.

When you ask Claude 3.7 Sonnet / Claude 4, GPT-4o / GPT-5, or Cursor to write a custom game mode in JASS or Lua, the model struggles. It hallucinates Blizzard native function signatures that do not exist, forgets the RemoveLocation boilerplate, and outputs code that fails to compile in WorldEdit.

In contrast, our engine publishes its complete schema through /llms.txt and /docs/map-editor/ai-creators. A large language model understands JSON schemas natively:

Prompt:
"Create a Tower Defense wave controller in Shards of Stone TriggerScript.
Spawn 6 goblin brawlers at region 'spawn_lane' every 20 seconds.
When a brawler reaches region 'temple_gate', decrement variable 'player_lives'
by 1 and remove the unit. If lives reach 0, trigger defeat for player 0."

Because the output is validated against a typed schema, the model produces syntactically valid logic on the first attempt. The author simply clicks Import in The Forge, presses Ctrl + Enter, and is immediately playtesting their custom game mode in the browser.

The in-browser Forge

A modern modding ecosystem cannot rely on legacy desktop software. The entire authoring pipeline for Shards of Stone lives in the browser at /map-editor.

The Forge combines:

  • Dual-mode editing: smooth switching between a 2D tile editing grid and a 3D Three.js perspective view.
  • Terrain height sculpting, ramp painting, water, lava, and doodad placement.
  • Unit and building placement with customized starting inventory and orders.
  • Per-map balance overrides: tweaking damage, health, and gold costs without touching global engine files.
  • Trigger creation with visual event-condition-action graphs or raw JSON AST editing.

Because maps are compact JSON files under 200KB, they can be shared via a URL or stored in local browser storage. There are no 50GB downloads, no third-party patching tools, and no lobby waiting rooms.

The open web takes the mantle

Warcraft III proved that player-created custom maps can birth entire genres. The open web provides the distribution network that classic RTS modding always deserved: frictionless links, instant loading, cross-platform play, and a safe runtime.

By abandoning procedural scripting for a deterministic, total JSON AST, we eliminated handle leaks, made multiplayer lockstep permanent, and turned AI assistants into collaborative level designers.

You can inspect the complete schema in our AI Creator documentation, explore the architecture breakdown on our /modding hub, build your own battlefield in /map-editor, or jump straight into the browser skirmish at /play today.

Questions

Why not run Lua or JavaScript for browser RTS triggers?

Arbitrary script engines execute non-deterministic floating-point math, iterate objects in platform-dependent orders, and allow runtime exceptions. Across Chrome, Firefox, and Safari, differing JIT optimizations in V8, SpiderMonkey, and JavaScriptCore cause immediate peer desyncs in lockstep multiplayer.

What causes Warcraft III JASS maps to lag over time?

JASS uses manual handle reference counting. Locations, unit groups, and boolexprs require explicit calls like RemoveLocation and DestroyGroup. Missing a single cleanup call in a periodic trigger leaks handle table slots permanently, steadily degrading frame rate and leading to crashes.

How does a closed JSON AST prevent runtime exceptions?

Every node in the Trigger AST is total. Missing units evaluate to nullUnit, division by zero yields zero instead of Infinity or NaN, and array accesses out of bounds return zero-values. The simulation loop cannot throw or crash mid-tick, even when an LLM writes imperfect logic.

How can AI models generate complete RTS maps without bugs?

Because map schemas, triggers, and balance overrides are pure typed JSON rather than imperative scripts, large language models can validate structure against schema contracts. They produce fully functional game modes without hallucinating missing API natives or leaking pointers.

A goblin gambling den beside the Troll-Vault shop, eight lit gear slot panels on its front

Loot, gear and a gambling den

A Kobold Digger drops a healing potion 6.4% of the time. Every drop chance in the game is a real number you can look up, not a designer's feeling.

9 min read
← All posts