Map Editor Documentation

Walkthrough: a spell brawl from scratch

A read-through of the Cinder Ring: Brink of Stone rebuilt on the public script API alone, with the pick, the shop, five spells, the shove, a ring that sinks into the lava, bots and the HUD all in one script.

Genres · Page 45 of 53

The Cinder Ring is up to eight casters on a dirt platform in a lake of lava. Every spell is a shove, every hit makes the next one throw further, and every so often the platform's outer ring sinks into the lava. The last caster standing takes the round.

Brink of Stone plays like this already, but Brink is an engine module switched on by rules.brink (Build a spell brawl). The Cinder Ring uses none of it. Its arena is painted terrain, its casters are unit clones in its Data, and everything else (the pick, the shop, the spells, the shove, the falling ring, the rounds, the bots and the HUD) is one script on the public sos API that you can read, change and copy. Read this chapter when you want a brawl that plays by rules the arena module does not offer, or when you want a worked example of a whole game written as a script.

Play it

Brink of Stone: Creator Example. The Custom Games gallery lists it under the same name.

To open it in the Forge, download brink-creator-example.play.json and choose File → Import Map JSON…. The script is under Scripts (F11) and the casters under Data.

What it is made of

PieceWhat the file says
Map48 tiles square, wasteland biome, mapType of arena
TerrainA dirt disc of radius 9.5 round (24, 24), shallow lava out to 15.5, deep lava past it, and four 2x2 rock pillars round the middle
SeatsEight. Seat 0 is user, seats 1 to 7 computer, each on a team of its own
RulesdisableAi: true, fog: 'off', standardVictory: false, noStartingUnits: true, allowedBuildings empty
DataNine unit overrides, one caster per people, each cloned from that people's spellcaster with no attack and no spells of its own
ScriptOne, arena, in JavaScript at the current API level
Not usedrules.brink, a custom runtime, a custom game controller, any engine module

The terrain does real work. Shallow lava is walkable and burns, so it is the hazard. Deep lava is a wall, so nobody is ever thrown off the map: a shoved caster lands in the shallows and burns there until they walk back or go down. The rock pillars stop missiles and casters both, and they sit inside the circle the casters start on, so no start is ever on rock.

The casters keep their people's art and lose everything else. Their health, speed and sight are the override's, and their spells are the script's.

The map is written by a generator, tools/maps/brink_creator_example.ts, which reads the script from tools/maps/brink_creator_example.arena.js. That script is the source of truth for every number in this chapter. Its three tables at the top, TUNING, CASTERS and SPELLS, are the game: change them first.

How a match flows

The script keeps the match's phase in sos.storage.phase and moves it on from lifecycle.onTick:

PhaseWhat happensWhat ends it
pickEveryone chooses a caster by clicking one of nine frames. Bots choose at onceEvery person has picked, or the clock runs out (TUNING.pickTicks); anyone left gets a random caster
shopEveryone spends gold on spells and levels, then clicks Ready. Bots shop at onceEvery person is ready, or the clock runs out
countdownThe platform is laid again, the casters appear round it, a person's caster is selected for themTUNING.countdownTicks
fightSpells, the shove, the falling ring, the botsOne caster or none left standing
roundEndThe round's point is given, missiles are cleared, everyone earns TUNING.goldPerRoundTUNING.roundEndTicks, then the shop again, or the results after the last round
doneThe results frame, then sos.game.victory for the top scorers and defeat for the rest

Everything a phase needs to survive a save lives in sos.storage: the phase, its deadline (until), the round, the platform's radius, and one record per seat with its pick, spell levels, cooldowns, points, kills, strain and unit.

Keys and clicks

Five keys cast, and the script takes them from the game's own hotkeys for the match, so pressing Q never also triggers a hotkey or moves the camera:

// Every player's keys arrive as 'key-press' events with the pointer's point.
// `true` takes them from the game's own hotkeys and camera for this match.
sos.input.listen(SPELL_IDS.map(function (id) { return SPELLS[id].key; }), true);

That call sits at the script's top level, so a loaded match listens again. Every press then arrives as a key-press event with the seat, the key and the point where that player's pointer met the ground. That point is the aim. The pick and the shop are clickable HUD frames, and a click arrives as a ui-click event with the seat and the frame's id:

sos.events.onEvent(function (e) {
  var now = sos.time.tick();
  var d = e.data;
  if (e.name === 'key-press') {
    if (d.down && d.x !== null && KEYS[d.key] && S.seats[d.seat]) cast(d.seat, KEYS[d.key], d.x, d.y, now);
  } else if (e.name === 'ui-click') {
    var id = d.frameId;
    if (id.indexOf('pick_') === 0 && id.indexOf('pick_me_') !== 0) pickFor(d.seat, Number(id.slice(5)));
    else if (id.indexOf('buy_') === 0) buy(d.seat, id.slice(4));
    else if (id === 'shop_ready' && S.phase === 'shop' && S.seats[d.seat]) {
      S.seats[d.seat].ready = true;
      run([update('shop_me_' + d.seat, null, shopLine(d.seat))]);
    }
  } else if (e.name === 'entity-died') {
    seats().forEach(function (s) { if (S.seats[s].unit === d.entityId) out(s, now); });
  }
});

The caster and spell buttons are one set of frames everybody sees, because the click itself says who clicked. What differs per person is the status line under them (pick_me_<seat>, shop_me_<seat>), a frame created with players: [s] so only that seat sees it. Right-click still walks, because walking is the game's own order and the script never claims the mouse.

Spells are script

No caster has a spell of its own. A spell here is three things the script does: check the cooldown, draw a look with sos.effects, and decide for itself what the spell hits.

Fireball flies. The cast starts a missile with sos.effects.missile, using the caster's own bolt from CASTERS, so each people's Fireball looks like theirs. It also pushes the script's own copy of that missile onto FLY, moving at the same speed, over the same flight time (the range divided by the speed). Each fight tick, stepMissiles moves every copy one step. A copy that reaches rock bursts. One that comes within reach of an enemy caster bursts and hits. Two enemy missiles that touch both burst. The burst ends the drawn missile where it is and plays the impact:

function burst(m) {
  sos.effects.stop(m.fx);
  sos.effects.play(SPELLS.fireball.hit, { x: m.x, y: m.y });
}

Lightning is instant: a ray from the caster towards the pointer that hits the first enemy within width of the line and inside range. It is drawn as a three-tick missile of its look spell, with the hit effect on whoever it struck. Meteor plays its warning on the spot at once and lands delay ticks later, hitting every other caster within radius with a shove that weakens with distance. Ward sets a time until which hits on that caster do nothing but flash.

Blink moves the caster towards the pointer, up to its range. It checks the ground first with sos.terrain.get, and steps back along the line until the spot is clear of the pillars, the deep lava and the map's edge:

// Never into a pillar or the deep lava: step back along the line until clear.
for (var tries = 0; tries < 6; tries++) {
  var ground = sos.terrain.get(dest);
  if (ground !== 'rock' && ground !== 'lava' && ground !== '') break;
  hop -= 1;
  dest = { x: me.x + ux * hop, y: me.y + uy * hop };
}

The shop sells all five. Fireball starts at level 1 for everyone, and a spell's cost buys its first level and upgrade each one after, up to max. Every per-level number (cooldown, damage, range, the Ward's length) is an array in SPELLS with one entry a level.

The shove

Every hit goes through one function. It keeps the caster's health in the script, adds the damage to the caster's strain, and throws them with the trigger action applyImpulse, run through sos.triggers.run:

function hit(by, s, dmg, knock, ux, uy, now) {
  var st = S.seats[s];
  if (!st.alive) return;
  if (now < st.shield) { sos.effects.play(SPELLS.shield.look, posOf(s)); return; }
  st.strain += dmg;
  st.lastBy = by;
  st.lastAt = now;
  HP[s] = (HP[s] === undefined ? 100 : HP[s]) - dmg;
  if (HP[s] <= 0) { sos.units.kill(st.unit); out(s, now); return; }
  sos.units.setHp(st.unit, HP[s]);
  // The shove: a hit throws 1 + strain/100 times as far as the first one did.
  var v = knock * (1 + st.strain / 100);
  run([{ k: 'applyImpulse', unit: st.unit, vx: ux * v, vy: uy * v, friction: TUNING.friction }]);
}

Strain only grows during a round, so the round starts with gentle taps and ends with casters flying across the platform. knock is a spell's speed in tiles a tick, and TUNING.friction is how much of the speed is left after each tick.

The ring that falls

Each fight schedules the next ring: TUNING.shrinkBaseTicks, plus TUNING.shrinkPerAliveTicks for every caster still standing, so a crowded platform holds longer than a duel. TUNING.warnTicks before it falls, the script paints the ring on an overlay layer, ring, defined in onInit. Painting a ring is a big block of values, so it goes out in four bands of rows, one band a tick (see Living inside the budget).

Then the ring falls:

function shrink(now) {
  sos.terrain.setRing(C, S.radius, 'shallowLava', Math.max(0, S.radius - 1));
  later(function () { sos.overlay.clear('ring'); });
  S.radius -= 1;
  if (S.radius > 0.6) scheduleShrink(now);
}

setRing turns every tile whose middle lies between the old radius and one tile in to shallow lava. The rock pillars are never touched, because runtime terrain never changes rock. When the shop closes, one more call lays the whole platform again, the same way round: sos.terrain.setRing(C, TUNING.startRadius, 'dirt'). Lava may turn back into dirt, so the next round starts on a whole platform. The rules are in Change the land.

Lava

The burning is the engine's: shallow lava hurts whoever wades it, with no script involved. The script only adjusts it, with a damage hook. Damage with no attacker is the lava, so the hook scales it down by TUNING.lavaDamageScale, counts half of it towards the strain, and keeps the script's own health count in step:

sos.hooks.onDamage(function (e) {
  if (e.attacker !== 0) return;
  var hurt = e.amount * TUNING.lavaDamageScale;
  seats().forEach(function (s) {
    if (S.seats[s].unit !== e.target) return;
    S.seats[s].strain += Math.round(hurt / 2);
    if (HP[s] !== undefined) HP[s] -= hurt;
  });
  return hurt;
});

A caster who burns to death is killed by the engine, and the script hears it as an entity-died event.

Points, kills and the end

A caster is out when they die, whoever killed them. The last caster to hit them, within TUNING.killCreditTicks, gets a point and a kill. That includes a caster thrown into the lava who burns out a few seconds later. The last caster standing gets a point for the round. After TUNING.rounds rounds the most points wins, and a tie is a shared win: sos.game.victory for every top scorer and sos.game.defeat for everyone else.

Bots for empty seats

onInit asks each seat who holds it with sos.players.get. A seat whose human is false is the script's to play. A bot picks a caster at once, shops in a shuffled order, and thinks every TUNING.botThinkTicks ticks, offset by seat so the bots do not all think on the same tick. A bot near the edge walks back towards the middle, or blinks there if it is already past the platform's edge. Otherwise it aims at the nearest enemy, a little off, wards when an enemy missile is close, and otherwise casts whatever is ready and in range.

The map's own AI is off (disableAi: true), so these few lines are the whole opponent. A person can learn them, which is fair in a game this short.

The HUD and the camera

Everything on screen is trigger UI, run from the script with sos.triggers.run:

  • A line at the top, updated once a second, says the phase and its clock.
  • The scoreboard is one frame at the top left, a line a seat (a frame's text keeps its line breaks). The script rewrites it only when points or kills change. A multiboard would do too, but it sits at the top right, where the HUD also lists the map's overlay layers, and the two would overlap.
  • During a fight each person has a line near the bottom of the screen with their spells' cooldowns, their Ward and their strain, updated once a second. Each person's line is updated on a different tick.
  • Kills and the start of a round are showText messages.

When a person's caster appears, sos.units.select selects it on their screen, so they can right-click to walk at once. In onInit the camera pans to the middle of the platform and locks there for everyone (cameraPan, then cameraLock), so the whole arena is always in view.

Living inside the budget

A script has 1,600 units of work a tick, and the biggest cost is rarely the script's own arithmetic. Every call's arguments and result cross into and out of the engine as JSON, charged by size (Working with the engine). The first version of this script read each caster with sos.units.get on every tick, and the gas strikes disabled it within the first fight. The shipped version is built round four habits:

  • One read for every caster. Each fight tick starts with a single sos.units.positions call for everyone standing:

    function readCasters(now) {
      var list = standing();
      if (!list.length) return;
      var at = sos.units.positions(list.map(function (s) { return S.seats[s].unit; }));
      list.forEach(function (s, i) {
        if (at[i * 2] < 0) { out(s, now); return; }
        POS[s] = { x: at[i * 2], y: at[i * 2 + 1] };
      });
    }
  • Small storage. sos.storage is written out at the end of every tick, so it holds only what a save must keep.

  • Plain variables for what can be had again. Positions (POS), the health count (HP), missiles in flight (FLY), meteors falling (FALL) and the job queue are ordinary variables, which a save does not keep. onLoad starts them again, so missiles in flight are simply gone after a load and each caster's health count starts full.

  • One big job a tick. Destroying the pick's frames, building the shop, painting a warning band, laying the platform and spawning each caster are jobs pushed onto a queue, and onTick runs one of them a tick:

    function later(fn) { QUEUE.push(fn); }

    A phase that needs the jobs finished, such as the shop closing or the fight starting, waits until the queue is empty (!QUEUE.length).

Multiplayer

The Cinder Ring is an ordinary scripted map, so it plays in lockstep: every key press and click is a command that reaches every player's script on the same tick, and every player's script makes the same decisions from it. Each person picks and shops for their own seat, because every click carries the seat that made it, and any seat nobody takes is a bot.

It has been soaked with four real lockstep clients:

npm run test:desync -- --map-file public/maps/brink-creator-example.play.json --players 4

Run it yourself

The map's own test plays a whole match headlessly, in the real simulation:

npx tsx tools/maps/brink_creator_example.test.ts

It checks that the shipped map is what the generator builds, and that it is a creator package (no rules.brink, no custom runtime, nine clones with no attack and no spells). Then one person and seven bots play a whole match: the person picks the fourth caster with a click, buys Lightning, and casts Fireball at the pointer. The test checks the phases run pick, shop and five rounds to the results, the ring falls into shallow lava and the platform is laid again, the top scorer wins, and the script never faults or comes near the gas budget. Last, two runs from one seed must agree on the script's hashed state, and a different seed must not.

After changing the script, write the map again and check it:

npx tsx tools/maps/brink_creator_example.ts
npx tsx tools/maps/brink_creator_example.ts --check

What is not here

Compared with Brink of Stone itself:

  • Five spells, not twenty. Each new one is a few lines in SPELLS and a branch in cast.
  • Free for all only. There are no teams, and no ally a spell passes through.
  • No setup screen. Brink opens in its own setup screen, where a player chooses the arena, the bots and the rounds. The Cinder Ring opens straight into its pick, with its rules fixed in the script.
  • Missiles are drawn in the 3D view only. The hits are the script's, so a match plays the same in either view, but the 2D view does not draw a missile in flight.
  • A ring that steps by whole tiles. The platform is terrain, so its edge is made of whole tiles, where the engine's arena draws a round platform of its own.
  • No sky arena. Runtime terrain cannot turn ground into the open sky, so a platform here can only sink into lava, not fall away. That needs 'void' as a runtime terrain kind, which is not possible yet.

Changing it

The numbers come first. Every field of TUNING, CASTERS and SPELLS is read by name, so a longer match, a slower ring, a harder shove or a cheaper Meteor is one edit. Then three remixes that need code:

  • Teams. Give the seats shared teams in Players, then skip allies in hit and in the missile test, and give a round to the last team standing.
  • More spells. Add an entry to SPELLS, its key to the list listen hears, a branch in cast, and a button in the shop.
  • A different arena. Paint another platform, move TUNING.centre and TUNING.startRadius to match, and keep the shallows wide enough that a thrown caster lands in them.

Edit in the Forge's Scripts module for a quick change, and use Test Map (Ctrl+F9) to play it. For a lasting change in the repository, edit brink_creator_example.arena.js and run the generator, so the map and the script cannot drift apart.

Where to go next