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
| Piece | What the file says |
|---|---|
| Map | 48 tiles square, wasteland biome, mapType of arena |
| Terrain | A 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 |
| Seats | Eight. Seat 0 is user, seats 1 to 7 computer, each on a team of its own |
| Rules | disableAi: true, fog: 'off', standardVictory: false, noStartingUnits: true, allowedBuildings empty |
| Data | Nine unit overrides, one caster per people, each cloned from that people's spellcaster with no attack and no spells of its own |
| Script | One, arena, in JavaScript at the current API level |
| Not used | rules.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:
| Phase | What happens | What ends it |
|---|---|---|
pick | Everyone chooses a caster by clicking one of nine frames. Bots choose at once | Every person has picked, or the clock runs out (TUNING.pickTicks); anyone left gets a random caster |
shop | Everyone spends gold on spells and levels, then clicks Ready. Bots shop at once | Every person is ready, or the clock runs out |
countdown | The platform is laid again, the casters appear round it, a person's caster is selected for them | TUNING.countdownTicks |
fight | Spells, the shove, the falling ring, the bots | One caster or none left standing |
roundEnd | The round's point is given, missiles are cleared, everyone earns TUNING.goldPerRound | TUNING.roundEndTicks, then the shop again, or the results after the last round |
done | The 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
showTextmessages.
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.positionscall 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.storageis 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.onLoadstarts 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
onTickruns 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 4Run it yourself
The map's own test plays a whole match headlessly, in the real simulation:
npx tsx tools/maps/brink_creator_example.test.tsIt 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 --checkWhat is not here
Compared with Brink of Stone itself:
- Five spells, not twenty. Each new one is a few lines in
SPELLSand a branch incast. - 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
hitand in the missile test, and give a round to the last team standing. - More spells. Add an entry to
SPELLS, its key to the listlistenhears, a branch incast, and a button in the shop. - A different arena. Paint another platform, move
TUNING.centreandTUNING.startRadiusto 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
- Build a spell brawl is the other way
to make one: the engine's arena, switched on with
rules.brink. - Your first sandboxed script covers every call this map makes: Input, Effects and Change the land.
- Lava & Biomes explains what lava does and the rule it keeps.
- Performance and limits lists the budgets this map is built round.
- Walkthrough: Tanks of Stone is another arena, written entirely in triggers.