{"v":3,"meta":{"name":"Cinder Ring","desc":"Brink of Stone rebuilt from scratch on the public script API, to open in the Forge and remix. Up to eight casters on a platform that sinks into the lava a ring at a time. Every spell is a shove, and each hit makes the next one throw further. Pick a caster, buy spells between rounds, and be the last one standing. Right-click walks; Q W E R F cast at the pointer. Empty seats are bots.","mapType":"arena","size":48,"biome":"wasteland","loadingScreen":"/assets/ui/custom-games/brink-of-stone-loading-v1.webp","env":{"timeOfDay":0.4313,"dayNightSpeed":0,"lightTint":16765614,"fogDensity":0.35,"sky":{"horizon":15897951,"zenith":3877498,"haze":16236686}}},"terrain":"48x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14|19x14,10x15,19x14|16x14,16x15,16x14|15x14,18x15,15x14|14x14,20x15,14x14|13x14,22x15,13x14|12x14,24x15,12x14|11x14,9x15,8x1,9x15,11x14|10x14,8x15,12x1,8x15,10x14|10x14,7x15,14x1,7x15,10x14|10x14,6x15,16x1,6x15,10x14|9x14,7x15,16x1,7x15,9x14|9x14,6x15,18x1,6x15,9x14|9x14,6x15,6x1,2x8,2x1,2x8,6x1,6x15,9x14|9x14,6x15,6x1,2x8,2x1,2x8,6x1,6x15,9x14|9x14,6x15,18x1,6x15,9x14|9x14,6x15,18x1,6x15,9x14|9x14,6x15,6x1,2x8,2x1,2x8,6x1,6x15,9x14|9x14,6x15,6x1,2x8,2x1,2x8,6x1,6x15,9x14|9x14,6x15,18x1,6x15,9x14|9x14,7x15,16x1,7x15,9x14|10x14,6x15,16x1,6x15,10x14|10x14,7x15,14x1,7x15,10x14|10x14,8x15,12x1,8x15,10x14|11x14,9x15,8x1,9x15,11x14|12x14,24x15,12x14|13x14,22x15,13x14|14x14,20x15,14x14|15x14,18x15,15x14|16x14,16x15,16x14|19x14,10x15,19x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14","spawns":[[30,24],[28,28],[24,30],[20,28],[18,24],[20,20],[24,18],[28,20]],"goldMines":[],"oilPatches":[],"baseTerrain":"48x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14|20x14,8x1,20x14|18x14,12x1,18x14|17x14,14x1,17x14|16x14,16x1,16x14|16x14,16x1,16x14|15x14,18x1,15x14|15x14,6x1,2x8,2x1,2x8,6x1,15x14|15x14,6x1,2x8,2x1,2x8,6x1,15x14|15x14,18x1,15x14|15x14,18x1,15x14|15x14,6x1,2x8,2x1,2x8,6x1,15x14|15x14,6x1,2x8,2x1,2x8,6x1,15x14|15x14,18x1,15x14|16x14,16x1,16x14|16x14,16x1,16x14|17x14,14x1,17x14|18x14,12x1,18x14|20x14,8x1,20x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14|48x14","players":[{"id":0,"controller":"user","race":"human","team":0,"res":{"gold":20,"lumber":0,"stone":0,"oil":0}},{"id":1,"controller":"computer","race":"dwarf","team":1,"res":{"gold":20,"lumber":0,"stone":0,"oil":0}},{"id":2,"controller":"computer","race":"high_elf","team":2,"res":{"gold":20,"lumber":0,"stone":0,"oil":0}},{"id":3,"controller":"computer","race":"dark_elf","team":3,"res":{"gold":20,"lumber":0,"stone":0,"oil":0}},{"id":4,"controller":"computer","race":"undead","team":4,"res":{"gold":20,"lumber":0,"stone":0,"oil":0}},{"id":5,"controller":"computer","race":"goblin","team":5,"res":{"gold":20,"lumber":0,"stone":0,"oil":0}},{"id":6,"controller":"computer","race":"wood_elf","team":6,"res":{"gold":20,"lumber":0,"stone":0,"oil":0}},{"id":7,"controller":"computer","race":"lizardmen","team":7,"res":{"gold":20,"lumber":0,"stone":0,"oil":0}}],"rules":{"noStartingUnits":true,"disableAi":true,"fog":"off","standardVictory":false,"allowedBuildings":[]},"scripts":[{"id":"arena","lang":"js","source":"// Cinder Ring: a spell brawl on a platform that shrinks into the lava.\n//\n// Brink of Stone rebuilt from scratch on the public script API, as a map you\n// can open in the Forge and remix. Nothing here is engine code: the casters are\n// unit clones in this map's Data, the platform is its terrain, and every rule\n// below (the pick, the shop, the spells, the shove, the falling ring, the\n// rounds, the bots and the HUD) is this script. The walkthrough is the Forge\n// chapter \"Walkthrough: a spell brawl from scratch\".\n//\n// Change the numbers in TUNING, SPELLS and CASTERS first; they are the game.\n//\n// Gas: a script has 1,600 cu a tick, and every op's arguments and answer cross\n// as JSON charged by the character. So this script keeps `sos.storage` small\n// (it is written out every tick), reads every caster in one units.positions\n// call, keeps what the world can tell it again (positions, missiles in flight)\n// in plain variables, and does big HUD and terrain work one job a tick.\n\n// ── Tuning ──────────────────────────────────────────────────────────────────\nvar TUNING = {\n  centre: { x: 24, y: 24 },   // the platform's middle, in tiles\n  startRadius: 9.5,           // the platform at the start of a round\n  rounds: 5,                  // rounds in a match (most points wins)\n  pickTicks: 400,             // 20 s to choose a caster (20 ticks a second)\n  shopTicks: 500,             // 25 s in the shop before the first round\n  shopTicksLater: 400,        // 20 s between rounds\n  countdownTicks: 60,         // 3 s\n  roundEndTicks: 80,          // 4 s\n  startGold: 20,\n  goldPerRound: 10,\n  killCreditTicks: 120,       // the last caster to hit you in 6 s gets the kill\n  friction: 0.86,             // how fast a shove dies away, per tick\n  lavaDamageScale: 0.6,       // lava burns at this share of the engine's rate\n  shrinkBaseTicks: 120,       // the ring falls every base + perAlive x casters standing\n  shrinkPerAliveTicks: 40,\n  warnTicks: 60,              // the next ring glows this long before it falls\n  botThinkTicks: 8,\n};\n\n// ── Casters (unit clones in this map's Data, one per people) ────────────────\n// `bolt` is the spell whose missile their Fireball flies with.\nvar CASTERS = [\n  { unit: 'cinder_human', name: 'Sunward Cleric', bolt: 'holy_bolt_hero' },\n  { unit: 'cinder_dwarf', name: 'Forge Runecaller', bolt: 'rune_blast' },\n  { unit: 'cinder_high_elf', name: 'Starfire Magus', bolt: 'starfire_bolt' },\n  { unit: 'cinder_dark_elf', name: 'Veil Sorceress', bolt: 'kit_sable_void_bolt' },\n  { unit: 'cinder_undead', name: 'Grave Necromancer', bolt: 'kit_morathis_soul_bolt' },\n  { unit: 'cinder_goblin', name: 'Bog Shaman', bolt: 'kit_thalion_firebolt' },\n  { unit: 'cinder_wood_elf', name: 'Grove Mage', bolt: 'starfire_arrow' },\n  { unit: 'cinder_lizardmen', name: 'Sun Priest', bolt: 'kit_huatec_solar_bolt' },\n  { unit: 'cinder_ratmen', name: 'Warp Warlock', bolt: 'kit_vexis_plague_bolt' },\n];\n\n// ── Spells (all of them script: a look by spell id, the rules here) ─────────\n// Levels: index 0 is level 1. `cost` buys level 1, `upgrade` each level after.\nvar SPELLS = {\n  fireball: { key: 'q', name: 'Fireball', info: 'a bolt that shoves', cost: 0, upgrade: 10, max: 5,\n    cd: [34, 31, 28, 25, 22], dmg: [8, 9, 10, 11, 12], knock: 0.30, speed: 0.55, range: 10, radius: 0.55, hit: 'merc_wildfire_blast' },\n  lightning: { key: 'w', name: 'Lightning', info: 'instant and short, a hard shove', cost: 15, upgrade: 10, max: 3,\n    cd: [120, 105, 90], dmg: [10, 12, 14], knock: 0.42, range: 7, width: 0.8, look: 'kit_tehenhauin_storm_bolt', hit: 'thunderstrike' },\n  meteor: { key: 'e', name: 'Meteor', info: 'falls on a spot after a second', cost: 20, upgrade: 10, max: 3,\n    cd: [200, 175, 150], dmg: [14, 17, 20], knock: 0.50, range: 9, radius: 2, delay: 20, warn: 'rain_of_fire', hit: 'meteoric_strike' },\n  blink: { key: 'r', name: 'Blink', info: 'step to the pointer', cost: 15, upgrade: 10, max: 3,\n    cd: [170, 145, 120], range: [5, 6, 7], look: 'kit_vaelindra_blink' },\n  shield: { key: 'f', name: 'Ward', info: 'no harm and no shove for a while', cost: 15, upgrade: 10, max: 3,\n    cd: [300, 270, 240], ticks: [30, 40, 50], look: 'sacred_shield' },\n};\nvar SPELL_IDS = ['fireball', 'lightning', 'meteor', 'blink', 'shield'];\nvar KEYS = {};\nSPELL_IDS.forEach(function (id) { KEYS[SPELLS[id].key] = id; });\n\n// Every player's keys arrive as 'key-press' events with the pointer's point.\n// `true` takes them from the game's own hotkeys and camera for this match.\nsos.input.listen(SPELL_IDS.map(function (id) { return SPELLS[id].key; }), true);\n\nvar S = sos.storage;\nvar C = TUNING.centre;\n\n// ── What this script keeps outside `sos.storage` ────────────────────────────\n// All of it can be had again from the world or the storage, so a loaded match\n// rebuilds it (`onLoad`): missiles in flight are simply gone after a load.\nvar LIST = null;   // the seats in play, ascending\nvar POS = {};      // seat -> { x, y }, read every fight tick\nvar HP = {};       // seat -> health, kept by the script's own hits and the lava hook\nvar FLY = [];      // missiles in flight\nvar FALL = [];     // meteors on their way down\nvar QUEUE = [];    // big jobs, one a tick\n\nfunction later(fn) { QUEUE.push(fn); }\nfunction seats() {\n  if (!LIST) {\n    LIST = [];\n    for (var k in (S.seats || {})) LIST.push(Number(k));\n    LIST.sort(function (a, b) { return a - b; });\n  }\n  return LIST;\n}\nfunction dist(ax, ay, bx, by) { var dx = ax - bx, dy = ay - by; return Math.sqrt(dx * dx + dy * dy); }\nfunction pt(x, y) { return { k: 'point', x: x, y: y }; }\nfunction run(actions) { if (actions.length > 0) sos.triggers.run(actions); }\nfunction humans() { return seats().filter(function (s) { return S.seats[s].human; }); }\nfunction standing() { return seats().filter(function (s) { return S.seats[s].alive; }); }\nfunction casterOf(s) { return CASTERS[S.seats[s].pick] || CASTERS[0]; }\nfunction label(s) { return 'P' + (s + 1) + ' ' + casterOf(s).name; }\nfunction posOf(s) { return POS[s] || C; }\nfunction spotOf(i, n) {\n  var a = (2 * Math.PI * i) / n;\n  var r = TUNING.startRadius * 0.6;\n  return { x: C.x + Math.cos(a) * r, y: C.y + Math.sin(a) * r };\n}\nfunction secondsLeft(now, until) { return Math.max(0, Math.ceil((until - now) / 20)); }\n\n// ── HUD (trigger UI frames, through sos.triggers.run) ───────────────────────\nvar PANEL = { backgroundColor: '#1c1210', borderColor: '#c4672a', textColor: '#f6e2c4', borderRadius: 6 };\nvar BUTTON = { backgroundColor: '#2a1712', borderColor: '#e08a3c', textColor: '#ffe9c9', borderRadius: 6 };\nfunction frame(id, anchor, x, y, w, h, title, text, extra) {\n  var f = { k: 'createUIFrame', frameId: id, anchor: anchor, x: x, y: y, width: w, height: h, style: PANEL };\n  if (title) f.title = title;\n  if (text) f.text = text;\n  for (var k in (extra || {})) f[k] = extra[k];\n  return f;\n}\nfunction update(id, title, text) {\n  var f = { k: 'updateUIFrame', frameId: id };\n  if (title !== null) f.title = title;\n  if (text !== null) f.text = text;\n  return f;\n}\nfunction destroy(ids) { return ids.map(function (id) { return { k: 'destroyUIFrame', frameId: id }; }); }\n\nfunction topText(now) {\n  if (S.phase === 'pick') return 'Choose your caster: ' + secondsLeft(now, S.until) + ' s';\n  if (S.phase === 'shop') return 'Round ' + (S.round + 1) + ' of ' + TUNING.rounds + ' next. The shop shuts in ' + secondsLeft(now, S.until) + ' s';\n  if (S.phase === 'countdown') return 'Round ' + S.round + ' of ' + TUNING.rounds + ' in ' + secondsLeft(now, S.until);\n  if (S.phase === 'fight') return 'Round ' + S.round + ' of ' + TUNING.rounds + ': ' + standing().length + ' standing. '\n    + (S.radius > 0.6 ? 'The ring falls in ' + secondsLeft(now, S.shrinkAt) + ' s' : 'The platform is gone');\n  if (S.phase === 'roundEnd') return S.banner;\n  return 'The match is over';\n}\n\n// The scoreboard: one frame at the top left, its text a line a seat (frame text\n// keeps line breaks). Kept off the top right, where the HUD lists overlays.\nfunction scoreText() {\n  return seats().map(function (s) {\n    var st = S.seats[s];\n    return label(s) + (st.human ? '' : ' (bot)') + ': ' + st.points + (st.points === 1 ? ' point' : ' points')\n      + (st.kills ? ', ' + st.kills + (st.kills === 1 ? ' kill' : ' kills') : '');\n  }).join('\\n');\n}\nfunction scoreboard() {\n  later(function () { run([frame('score', 'top_left', 12, 96, 330, 44 + 18 * seats().length, 'Points', scoreText())]); });\n}\nfunction scoreRow() { run([update('score', null, scoreText())]); }\n\n// ── The pick ────────────────────────────────────────────────────────────────\nfunction showPick() {\n  var acts = [frame('top', 'top_center', 0, 70, 520, 56, 'Cinder Ring', 'Choose your caster'),\n    frame('pick_title', 'center', 0, -170, 640, 56, 'Choose your caster', 'Every caster plays the same. Their Fireball looks their own.')];\n  CASTERS.forEach(function (c, i) {\n    var col = i % 3, row = Math.floor(i / 3);\n    acts.push(frame('pick_' + i, 'center', (col - 1) * 215, (row - 1) * 70 - 30, 205, 60, c.name, 'Click to play', { clickable: true, style: BUTTON }));\n  });\n  humans().forEach(function (s) { acts.push(frame('pick_me_' + s, 'center', 0, 150, 420, 44, null, 'You have not chosen yet.', { players: [s] })); });\n  run(acts);\n}\nfunction pickFor(s, i) {\n  var st = S.seats[s];\n  if (!st || S.phase !== 'pick' || !(i >= 0 && i < CASTERS.length)) return;\n  st.pick = i;\n  st.picked = true;\n  if (st.human) run([update('pick_me_' + s, null, 'You play the ' + CASTERS[i].name + '. Waiting for the others.')]);\n}\nfunction endPick(now) {\n  S.phase = 'shop';\n  S.until = now + TUNING.shopTicks + 20;\n  seats().forEach(function (s) { if (!S.seats[s].picked) { S.seats[s].pick = sos.rng.int(0, CASTERS.length - 1); S.seats[s].picked = true; } });\n  later(function () {\n    var ids = ['pick_title'];\n    CASTERS.forEach(function (c, i) { ids.push('pick_' + i); });\n    humans().forEach(function (s) { ids.push('pick_me_' + s); });\n    run(destroy(ids));\n  });\n  scoreboard();\n  openShop(now, TUNING.shopTicks);\n}\n\n// ── The shop ────────────────────────────────────────────────────────────────\nfunction priceOf(s, id) {\n  var lvl = S.seats[s].levels[id] || 0;\n  var sp = SPELLS[id];\n  if (lvl >= sp.max) return -1;\n  return lvl === 0 ? sp.cost : sp.upgrade;\n}\nfunction shopLine(s) {\n  var st = S.seats[s];\n  var parts = SPELL_IDS.map(function (id) {\n    var lvl = st.levels[id] || 0;\n    return SPELLS[id].key.toUpperCase() + ' ' + SPELLS[id].name + ' ' + (lvl || '-');\n  });\n  return 'Gold ' + sos.players.resource(s, 'gold') + '. ' + parts.join(', ') + (st.ready ? '. Ready.' : '');\n}\nfunction openShop(now, ticks) {\n  S.phase = 'shop';\n  S.until = now + ticks + 4 + humans().length;\n  seats().forEach(function (s) { S.seats[s].ready = false; if (!S.seats[s].human) later(function () { botShop(s); }); });\n  later(function () {\n    run([frame('shop_title', 'center', 0, -190, 660, 56, 'The spell shop', 'Gold buys a spell, then levels: more harm, less wait. Click Ready when you are done.'),\n      frame('shop_ready', 'center', 165, 12, 320, 58, 'Ready', 'Start the round when everyone is ready', { clickable: true, style: BUTTON })]);\n  });\n  [[0, 1, 2], [3, 4]].forEach(function (group) {\n    later(function () {\n      run(group.map(function (i) {\n        var id = SPELL_IDS[i], sp = SPELLS[id];\n        var price = sp.cost ? sp.cost + ' gold, +' + sp.upgrade + ' a level' : 'yours; +' + sp.upgrade + ' a level';\n        return frame('buy_' + id, 'center', i % 2 === 0 ? -165 : 165, Math.floor(i / 2) * 66 - 120, 320, 58,\n          sp.key.toUpperCase() + '  ' + sp.name + ' (to level ' + sp.max + ')', sp.info + '. ' + price, { clickable: true, style: BUTTON });\n      }));\n    });\n  });\n  humans().forEach(function (s) { later(function () { run([frame('shop_me_' + s, 'center', 0, 90, 660, 50, null, shopLine(s), { players: [s] })]); }); });\n}\nfunction buy(s, id) {\n  var st = S.seats[s];\n  if (!st || S.phase !== 'shop' || !SPELLS[id]) return false;\n  var price = priceOf(s, id);\n  var gold = sos.players.resource(s, 'gold');\n  if (price < 0 || gold < price) return false;\n  sos.players.setResource(s, 'gold', gold - price);\n  st.levels[id] = (st.levels[id] || 0) + 1;\n  if (st.human) run([update('shop_me_' + s, null, shopLine(s))]);\n  return true;\n}\nfunction closeShop(now) {\n  S.phase = 'countdown';\n  S.until = now + TUNING.countdownTicks;\n  S.round += 1;\n  S.radius = TUNING.startRadius;\n  FLY = []; FALL = [];\n  later(function () {\n    var ids = ['shop_title', 'shop_ready'];\n    SPELL_IDS.forEach(function (id) { ids.push('buy_' + id); });\n    humans().forEach(function (s) { ids.push('shop_me_' + s); });\n    run(destroy(ids));\n  });\n  later(function () { sos.terrain.setRing(C, TUNING.startRadius, 'dirt'); sos.overlay.clear('ring'); });\n  var list = seats();\n  list.forEach(function (s, i) { later(function () { spawn(s, spotOf(i, list.length)); }); });\n}\nfunction spawn(s, at) {\n  var st = S.seats[s];\n  if (st.unit) sos.units.remove(st.unit);\n  var ids = sos.units.spawn(casterOf(s).unit, 1, s, at, Math.atan2(C.y - at.y, C.x - at.x));\n  st.unit = ids.length ? ids[0] : 0;\n  st.alive = st.unit > 0;\n  st.strain = 0; st.cd = {}; st.shield = 0; st.lastBy = -1; st.lastAt = -1000; st.botAt = 0;\n  POS[s] = { x: at.x, y: at.y };\n  HP[s] = 100;\n  if (st.human && st.unit) {\n    sos.units.select(s, [st.unit]);\n    run([frame('cd_' + s, 'bottom_center', 0, 210, 640, 40, null, 'Q W E R F cast at the pointer. Right-click to walk.', { players: [s] })]);\n  }\n}\n\n// ── A round ─────────────────────────────────────────────────────────────────\nfunction startFight(now) {\n  S.phase = 'fight';\n  scheduleShrink(now);\n  run([{ k: 'showText', players: null, text: 'Fight! Right-click to walk; Q W E R F cast at the pointer.', durationTicks: 80 }]);\n}\nfunction scheduleShrink(now) {\n  S.shrinkAt = now + TUNING.shrinkBaseTicks + TUNING.shrinkPerAliveTicks * standing().length;\n  S.warned = false;\n}\n// Paint the ring that falls next on an overlay layer, in four bands of rows (a job each).\nfunction warnRing() {\n  S.warned = true;\n  var r = S.radius;\n  var x0 = Math.floor(C.x - r), y0 = Math.floor(C.y - r), w = Math.ceil(C.x + r) - x0 + 1;\n  var o2 = r * r, i2 = (r - 1) * (r - 1);\n  var band = Math.ceil(w / 4);\n  for (var b = 0; b < 4; b++) {\n    (function (top) {\n      later(function () {\n        var values = [];\n        for (var y = top; y < Math.min(y0 + w, top + band); y++) {\n          for (var x = x0; x < x0 + w; x++) {\n            var dx = x + 0.5 - C.x, dy = y + 0.5 - C.y, d2 = dx * dx + dy * dy;\n            values.push(d2 < o2 && d2 >= i2 ? 1 : null);\n          }\n        }\n        if (values.length) sos.overlay.paint('ring', { x: x0, y: top }, w, values);\n      });\n    })(y0 + b * band);\n  }\n}\nfunction shrink(now) {\n  sos.terrain.setRing(C, S.radius, 'shallowLava', Math.max(0, S.radius - 1));\n  later(function () { sos.overlay.clear('ring'); });\n  S.radius -= 1;\n  if (S.radius > 0.6) scheduleShrink(now);\n}\nfunction out(s, now) {\n  var st = S.seats[s];\n  if (!st.alive) return;\n  st.alive = false;\n  var by = st.lastBy;\n  if (by >= 0 && by !== s && S.seats[by] && now - st.lastAt <= TUNING.killCreditTicks) {\n    S.seats[by].points += 1;\n    S.seats[by].kills += 1;\n    run([{ k: 'showText', players: null, text: label(by) + ' threw ' + label(s) + ' out.', durationTicks: 60 }]);\n    later(scoreRow);\n  }\n}\nfunction endRound(now) {\n  var left = standing();\n  if (left.length === 1) {\n    S.seats[left[0]].points += 1;\n    S.banner = 'Round ' + S.round + ' to ' + label(left[0]) + '.';\n    later(scoreRow);\n  } else {\n    S.banner = 'Round ' + S.round + ': nobody left standing.';\n  }\n  S.phase = 'roundEnd';\n  S.until = now + TUNING.roundEndTicks;\n  var flying = FLY;\n  FLY = []; FALL = [];\n  later(function () { flying.forEach(function (m) { sos.effects.stop(m.fx); }); });\n  seats().forEach(function (s) { sos.players.addResource(s, 'gold', TUNING.goldPerRound); });\n  later(function () { run(destroy(humans().map(function (s) { return 'cd_' + s; }))); });\n  later(function () { run([update('top', null, S.banner)]); });\n}\nfunction endMatch() {\n  S.phase = 'done';\n  var best = -1, winners = [];\n  seats().forEach(function (s) {\n    var p = S.seats[s].points;\n    if (p > best) { best = p; winners = [s]; } else if (p === best) winners.push(s);\n  });\n  seats().forEach(function (s) { later(function () { if (S.seats[s].unit) sos.units.remove(S.seats[s].unit); S.seats[s].unit = 0; }); });\n  var names = winners.map(label).join(' and ');\n  later(function () {\n    run([frame('results', 'center', 0, -40, 560, 110, winners.length > 1 ? 'A shared win' : 'Victory',\n      names + ' with ' + best + (best === 1 ? ' point' : ' points') + ' after ' + S.round + ' rounds.'),\n      update('top', null, 'The match is over')]);\n  });\n  later(function () {\n    winners.forEach(function (s) { sos.game.victory(s); });\n    seats().forEach(function (s) { if (winners.indexOf(s) < 0) sos.game.defeat(s); });\n  });\n}\n\n// ── Casting ─────────────────────────────────────────────────────────────────\nfunction ready(s, id, now) {\n  var st = S.seats[s];\n  return st.alive && (st.levels[id] || 0) > 0 && now >= (st.cd[id] || 0);\n}\nfunction cast(s, id, x, y, now) {\n  var st = S.seats[s];\n  if (S.phase !== 'fight' || !ready(s, id, now)) return;\n  var sp = SPELLS[id];\n  var lvl = st.levels[id];\n  st.cd[id] = now + sp.cd[lvl - 1];\n  var me = posOf(s);\n  var dx = x - me.x, dy = y - me.y;\n  var d = Math.sqrt(dx * dx + dy * dy) || 1;\n  var ux = dx / d, uy = dy / d;\n  if (id === 'fireball') {\n    var ticks = Math.round(sp.range / sp.speed);\n    var from = { x: me.x + ux * 0.6, y: me.y + uy * 0.6 };\n    var to = { x: from.x + ux * sp.range, y: from.y + uy * sp.range };\n    var fx = sos.effects.missile(casterOf(s).bolt, from, to, ticks);\n    FLY.push({ fx: fx, seat: s, x: from.x, y: from.y, vx: ux * sp.speed, vy: uy * sp.speed, left: ticks,\n      r: sp.radius, dmg: sp.dmg[lvl - 1], knock: sp.knock });\n  } else if (id === 'lightning') {\n    // Instant: the first enemy within `width` of the line.\n    var best = -1, bestT = sp.range;\n    seats().forEach(function (o) {\n      if (o === s || !S.seats[o].alive) return;\n      var p = posOf(o), px = p.x - me.x, py = p.y - me.y;\n      var t = px * ux + py * uy;\n      if (t > 0 && t < bestT && Math.abs(px * uy - py * ux) < sp.width) { best = o; bestT = t; }\n    });\n    var end = { x: me.x + ux * bestT, y: me.y + uy * bestT };\n    sos.effects.missile(sp.look, me, end, 3);\n    if (best >= 0) {\n      sos.effects.play(sp.hit, end);\n      hit(s, best, sp.dmg[lvl - 1], sp.knock, ux, uy, now);\n    }\n  } else if (id === 'meteor') {\n    var reach = Math.min(d, sp.range);\n    var at = { x: me.x + ux * reach, y: me.y + uy * reach };\n    FALL.push({ seat: s, x: at.x, y: at.y, at: now + sp.delay, dmg: sp.dmg[lvl - 1] });\n    sos.effects.play(sp.warn, at);\n  } else if (id === 'blink') {\n    var hop = Math.min(d, sp.range[lvl - 1]);\n    var dest = { x: me.x + ux * hop, y: me.y + uy * hop };\n    // Never into a pillar or the deep lava: step back along the line until clear.\n    for (var tries = 0; tries < 6; tries++) {\n      var ground = sos.terrain.get(dest);\n      if (ground !== 'rock' && ground !== 'lava' && ground !== '') break;\n      hop -= 1;\n      dest = { x: me.x + ux * hop, y: me.y + uy * hop };\n    }\n    sos.effects.play(sp.look, me);\n    sos.units.order([st.unit], 'stop', s);\n    sos.units.move(st.unit, dest);\n    sos.effects.play(sp.look, dest);\n    POS[s] = dest;\n  } else if (id === 'shield') {\n    st.shield = now + sp.ticks[lvl - 1];\n    sos.effects.play(sp.look, me);\n  }\n}\nfunction hit(by, s, dmg, knock, ux, uy, now) {\n  var st = S.seats[s];\n  if (!st.alive) return;\n  if (now < st.shield) { sos.effects.play(SPELLS.shield.look, posOf(s)); return; }\n  st.strain += dmg;\n  st.lastBy = by;\n  st.lastAt = now;\n  HP[s] = (HP[s] === undefined ? 100 : HP[s]) - dmg;\n  if (HP[s] <= 0) { sos.units.kill(st.unit); out(s, now); return; }\n  sos.units.setHp(st.unit, HP[s]);\n  // The shove: a hit throws 1 + strain/100 times as far as the first one did.\n  var v = knock * (1 + st.strain / 100);\n  run([{ k: 'applyImpulse', unit: st.unit, vx: ux * v, vy: uy * v, friction: TUNING.friction }]);\n}\nfunction burst(m) {\n  sos.effects.stop(m.fx);\n  sos.effects.play(SPELLS.fireball.hit, { x: m.x, y: m.y });\n}\n\n// ── Each tick of a fight ────────────────────────────────────────────────────\nfunction readCasters(now) {\n  var list = standing();\n  if (!list.length) return;\n  var at = sos.units.positions(list.map(function (s) { return S.seats[s].unit; }));\n  list.forEach(function (s, i) {\n    if (at[i * 2] < 0) { out(s, now); return; }\n    POS[s] = { x: at[i * 2], y: at[i * 2 + 1] };\n  });\n}\nfunction stepMissiles(now) {\n  var keep = [];\n  for (var i = 0; i < FLY.length; i++) {\n    var m = FLY[i];\n    if (m.gone) continue;\n    m.x += m.vx; m.y += m.vy; m.left -= 1;\n    if (sos.terrain.get({ x: m.x, y: m.y }) === 'rock') { burst(m); continue; }\n    var struck = -1;\n    var list = seats();\n    for (var k = 0; k < list.length && struck < 0; k++) {\n      var s = list[k];\n      if (s === m.seat || !S.seats[s].alive) continue;\n      var p = posOf(s);\n      if (dist(p.x, p.y, m.x, m.y) < m.r + 0.45) struck = s;\n    }\n    if (struck >= 0) {\n      burst(m);\n      var len = Math.sqrt(m.vx * m.vx + m.vy * m.vy) || 1;\n      hit(m.seat, struck, m.dmg, m.knock, m.vx / len, m.vy / len, now);\n      continue;\n    }\n    // Two enemy missiles that touch both burst.\n    for (var j = i + 1; j < FLY.length; j++) {\n      var o = FLY[j];\n      if (!o.gone && o.seat !== m.seat && dist(o.x, o.y, m.x, m.y) < m.r + o.r) { burst(o); o.gone = true; m.gone = true; break; }\n    }\n    if (m.gone) { burst(m); continue; }\n    if (m.left > 0) keep.push(m);\n  }\n  FLY = keep;\n}\nfunction stepMeteors(now) {\n  var keep = [];\n  FALL.forEach(function (m) {\n    if (now < m.at) { keep.push(m); return; }\n    var sp = SPELLS.meteor;\n    sos.effects.play(sp.hit, { x: m.x, y: m.y });\n    seats().forEach(function (s) {\n      if (s === m.seat || !S.seats[s].alive) return;\n      var p = posOf(s), d = dist(p.x, p.y, m.x, m.y);\n      if (d >= sp.radius) return;\n      var ux = d > 0.01 ? (p.x - m.x) / d : 1, uy = d > 0.01 ? (p.y - m.y) / d : 0;\n      hit(m.seat, s, m.dmg, sp.knock * (1 - d / (sp.radius * 2)), ux, uy, now);\n    });\n  });\n  FALL = keep;\n}\nfunction cooldownLine(s, now) {\n  var st = S.seats[s];\n  return SPELL_IDS.filter(function (id) { return (st.levels[id] || 0) > 0; }).map(function (id) {\n    var wait = secondsLeft(now, st.cd[id] || 0);\n    return SPELLS[id].key.toUpperCase() + ' ' + SPELLS[id].name + (wait > 0 ? ' ' + wait + 's' : ' ready');\n  }).join('   ') + (now < st.shield ? '   WARDED' : '') + '   Strain ' + st.strain;\n}\n\n// ── Bots (seats nobody plays) ───────────────────────────────────────────────\nfunction botShop(s) {\n  var order = ['lightning', 'meteor', 'blink', 'fireball', 'shield'];\n  for (var i = order.length - 1; i > 0; i--) { var j = sos.rng.int(0, i); var t = order[i]; order[i] = order[j]; order[j] = t; }\n  for (var pass = 0; pass < 3; pass++) order.forEach(function (id) { buy(s, id); });\n}\nfunction botThink(s, now) {\n  var st = S.seats[s];\n  if (!st.alive) return;\n  var me = posOf(s);\n  var dc = dist(me.x, me.y, C.x, C.y);\n  var r = Math.max(1, S.radius);\n  if (dc > r - 1.2) {\n    var ax = C.x + (me.x - C.x) / (dc || 1) * r * 0.35, ay = C.y + (me.y - C.y) / (dc || 1) * r * 0.35;\n    if (dc > r + 0.3 && ready(s, 'blink', now)) cast(s, 'blink', ax, ay, now);\n    else sos.units.order([st.unit], 'move', s, { x: ax, y: ay });\n    st.botAt = now;\n    return;\n  }\n  var target = -1, td = 99;\n  seats().forEach(function (o) {\n    if (o === s || !S.seats[o].alive) return;\n    var p = posOf(o), d = dist(me.x, me.y, p.x, p.y);\n    if (d < td) { td = d; target = o; }\n  });\n  if (target < 0) return;\n  var tp = posOf(target);\n  var aimX = tp.x + (sos.rng.float() - 0.5) * 1.2, aimY = tp.y + (sos.rng.float() - 0.5) * 1.2;\n  if (ready(s, 'shield', now) && FLY.some(function (m) { return m.seat !== s && dist(m.x, m.y, me.x, me.y) < 2.2; })) cast(s, 'shield', me.x, me.y, now);\n  else if (td < 6.5 && ready(s, 'lightning', now)) cast(s, 'lightning', aimX, aimY, now);\n  else if (td < 9 && ready(s, 'meteor', now) && sos.rng.chance(0.35)) cast(s, 'meteor', aimX, aimY, now);\n  else if (td < 10.5 && ready(s, 'fireball', now)) cast(s, 'fireball', aimX, aimY, now);\n  else if (now - st.botAt > 50 && sos.rng.chance(0.3)) {\n    var a = sos.rng.float() * Math.PI * 2, rr = sos.rng.float() * r * 0.55;\n    sos.units.order([st.unit], 'move', s, { x: C.x + Math.cos(a) * rr, y: C.y + Math.sin(a) * rr });\n    st.botAt = now;\n  }\n}\n\n// ── The match ───────────────────────────────────────────────────────────────\nsos.lifecycle.onInit(function (e) {\n  S.seats = {};\n  for (var s = 0; s < 8; s++) {\n    var info = sos.players.get(s);\n    if (!info) continue;\n    S.seats[s] = { human: info.human, pick: -1, picked: false, levels: { fireball: 1 }, points: 0, kills: 0,\n      unit: 0, alive: false, strain: 0, cd: {}, shield: 0, lastBy: -1, lastAt: -1000, botAt: 0, ready: false };\n    sos.players.setResource(s, 'gold', TUNING.startGold);\n  }\n  LIST = null;\n  S.round = 0;\n  S.radius = TUNING.startRadius;\n  S.banner = '';\n  S.phase = 'pick';\n  S.until = e.tick + TUNING.pickTicks;\n  sos.overlay.define('ring', { name: 'The next ring to fall', legend: 'These tiles turn to lava next',\n    colors: ['#ff4d1a', '#ffc46b'], min: 0, max: 1, opacity: 55, smooth: false, show: true });\n  run([{ k: 'cameraPan', players: null, to: pt(C.x, C.y), durationTicks: 0 }, { k: 'cameraLock', players: null, at: pt(C.x, C.y) }]);\n  seats().forEach(function (s2) { if (!S.seats[s2].human) pickFor(s2, sos.rng.int(0, CASTERS.length - 1)); });\n  showPick();\n});\n\n// A loaded match: what the script keeps outside storage comes back from it.\nsos.lifecycle.onLoad(function () {\n  LIST = null; POS = {}; HP = {}; FLY = []; FALL = []; QUEUE = [];\n  seats().forEach(function (s) { HP[s] = 100; });\n});\n\nsos.events.onEvent(function (e) {\n  var now = sos.time.tick();\n  var d = e.data;\n  if (e.name === 'key-press') {\n    if (d.down && d.x !== null && KEYS[d.key] && S.seats[d.seat]) cast(d.seat, KEYS[d.key], d.x, d.y, now);\n  } else if (e.name === 'ui-click') {\n    var id = d.frameId;\n    if (id.indexOf('pick_') === 0 && id.indexOf('pick_me_') !== 0) pickFor(d.seat, Number(id.slice(5)));\n    else if (id.indexOf('buy_') === 0) buy(d.seat, id.slice(4));\n    else if (id === 'shop_ready' && S.phase === 'shop' && S.seats[d.seat]) {\n      S.seats[d.seat].ready = true;\n      run([update('shop_me_' + d.seat, null, shopLine(d.seat))]);\n    }\n  } else if (e.name === 'entity-died') {\n    seats().forEach(function (s) { if (S.seats[s].unit === d.entityId) out(s, now); });\n  }\n});\n\n// Lava is the engine's own (shallow lava burns whoever wades it); this softens\n// it and counts half of each burn toward the strain.\nsos.hooks.onDamage(function (e) {\n  if (e.attacker !== 0) return;\n  var hurt = e.amount * TUNING.lavaDamageScale;\n  seats().forEach(function (s) {\n    if (S.seats[s].unit !== e.target) return;\n    S.seats[s].strain += Math.round(hurt / 2);\n    if (HP[s] !== undefined) HP[s] -= hurt;\n  });\n  return hurt;\n});\n\nsos.lifecycle.onTick(function (e) {\n  var now = e.tick;\n  if (QUEUE.length) QUEUE.shift()();\n  if (S.phase === 'pick') {\n    var waiting = humans().some(function (s) { return !S.seats[s].picked; });\n    if (!waiting || now >= S.until) endPick(now);\n  } else if (S.phase === 'shop') {\n    var shopping = humans().some(function (s) { return !S.seats[s].ready; });\n    if ((!shopping || now >= S.until) && !QUEUE.length) closeShop(now);\n  } else if (S.phase === 'countdown') {\n    if (now >= S.until && !QUEUE.length) startFight(now);\n  } else if (S.phase === 'fight') {\n    readCasters(now);\n    stepMissiles(now);\n    stepMeteors(now);\n    if (S.radius > 0.6) {\n      if (!S.warned && now >= S.shrinkAt - TUNING.warnTicks) warnRing();\n      else if (now >= S.shrinkAt && !QUEUE.length) shrink(now);\n    }\n    var list = seats();\n    for (var i = 0; i < list.length; i++) {\n      var s = list[i];\n      if (!S.seats[s].human && (now + s) % TUNING.botThinkTicks === 0) botThink(s, now);\n    }\n    if (standing().length <= 1) endRound(now);\n  } else if (S.phase === 'roundEnd') {\n    if (now >= S.until && !QUEUE.length) {\n      if (S.round >= TUNING.rounds) endMatch();\n      else openShop(now, TUNING.shopTicksLater);\n    }\n  }\n  // The HUD: the top line once a second, each player's spells once a second, staggered.\n  if (S.phase !== 'done') {\n    if (now % 20 === 0) run([update('top', null, topText(now))]);\n    if (S.phase === 'fight' || S.phase === 'countdown') {\n      humans().forEach(function (s) {\n        if (S.seats[s].unit && now % 20 === (s * 2 + 7) % 20) run([update('cd_' + s, null, cooldownLine(s, now))]);\n      });\n    }\n  }\n});\n","compiled":"// Cinder Ring: a spell brawl on a platform that shrinks into the lava.\n//\n// Brink of Stone rebuilt from scratch on the public script API, as a map you\n// can open in the Forge and remix. Nothing here is engine code: the casters are\n// unit clones in this map's Data, the platform is its terrain, and every rule\n// below (the pick, the shop, the spells, the shove, the falling ring, the\n// rounds, the bots and the HUD) is this script. The walkthrough is the Forge\n// chapter \"Walkthrough: a spell brawl from scratch\".\n//\n// Change the numbers in TUNING, SPELLS and CASTERS first; they are the game.\n//\n// Gas: a script has 1,600 cu a tick, and every op's arguments and answer cross\n// as JSON charged by the character. So this script keeps `sos.storage` small\n// (it is written out every tick), reads every caster in one units.positions\n// call, keeps what the world can tell it again (positions, missiles in flight)\n// in plain variables, and does big HUD and terrain work one job a tick.\n\n// ── Tuning ──────────────────────────────────────────────────────────────────\nvar TUNING = {\n  centre: { x: 24, y: 24 },   // the platform's middle, in tiles\n  startRadius: 9.5,           // the platform at the start of a round\n  rounds: 5,                  // rounds in a match (most points wins)\n  pickTicks: 400,             // 20 s to choose a caster (20 ticks a second)\n  shopTicks: 500,             // 25 s in the shop before the first round\n  shopTicksLater: 400,        // 20 s between rounds\n  countdownTicks: 60,         // 3 s\n  roundEndTicks: 80,          // 4 s\n  startGold: 20,\n  goldPerRound: 10,\n  killCreditTicks: 120,       // the last caster to hit you in 6 s gets the kill\n  friction: 0.86,             // how fast a shove dies away, per tick\n  lavaDamageScale: 0.6,       // lava burns at this share of the engine's rate\n  shrinkBaseTicks: 120,       // the ring falls every base + perAlive x casters standing\n  shrinkPerAliveTicks: 40,\n  warnTicks: 60,              // the next ring glows this long before it falls\n  botThinkTicks: 8,\n};\n\n// ── Casters (unit clones in this map's Data, one per people) ────────────────\n// `bolt` is the spell whose missile their Fireball flies with.\nvar CASTERS = [\n  { unit: 'cinder_human', name: 'Sunward Cleric', bolt: 'holy_bolt_hero' },\n  { unit: 'cinder_dwarf', name: 'Forge Runecaller', bolt: 'rune_blast' },\n  { unit: 'cinder_high_elf', name: 'Starfire Magus', bolt: 'starfire_bolt' },\n  { unit: 'cinder_dark_elf', name: 'Veil Sorceress', bolt: 'kit_sable_void_bolt' },\n  { unit: 'cinder_undead', name: 'Grave Necromancer', bolt: 'kit_morathis_soul_bolt' },\n  { unit: 'cinder_goblin', name: 'Bog Shaman', bolt: 'kit_thalion_firebolt' },\n  { unit: 'cinder_wood_elf', name: 'Grove Mage', bolt: 'starfire_arrow' },\n  { unit: 'cinder_lizardmen', name: 'Sun Priest', bolt: 'kit_huatec_solar_bolt' },\n  { unit: 'cinder_ratmen', name: 'Warp Warlock', bolt: 'kit_vexis_plague_bolt' },\n];\n\n// ── Spells (all of them script: a look by spell id, the rules here) ─────────\n// Levels: index 0 is level 1. `cost` buys level 1, `upgrade` each level after.\nvar SPELLS = {\n  fireball: { key: 'q', name: 'Fireball', info: 'a bolt that shoves', cost: 0, upgrade: 10, max: 5,\n    cd: [34, 31, 28, 25, 22], dmg: [8, 9, 10, 11, 12], knock: 0.30, speed: 0.55, range: 10, radius: 0.55, hit: 'merc_wildfire_blast' },\n  lightning: { key: 'w', name: 'Lightning', info: 'instant and short, a hard shove', cost: 15, upgrade: 10, max: 3,\n    cd: [120, 105, 90], dmg: [10, 12, 14], knock: 0.42, range: 7, width: 0.8, look: 'kit_tehenhauin_storm_bolt', hit: 'thunderstrike' },\n  meteor: { key: 'e', name: 'Meteor', info: 'falls on a spot after a second', cost: 20, upgrade: 10, max: 3,\n    cd: [200, 175, 150], dmg: [14, 17, 20], knock: 0.50, range: 9, radius: 2, delay: 20, warn: 'rain_of_fire', hit: 'meteoric_strike' },\n  blink: { key: 'r', name: 'Blink', info: 'step to the pointer', cost: 15, upgrade: 10, max: 3,\n    cd: [170, 145, 120], range: [5, 6, 7], look: 'kit_vaelindra_blink' },\n  shield: { key: 'f', name: 'Ward', info: 'no harm and no shove for a while', cost: 15, upgrade: 10, max: 3,\n    cd: [300, 270, 240], ticks: [30, 40, 50], look: 'sacred_shield' },\n};\nvar SPELL_IDS = ['fireball', 'lightning', 'meteor', 'blink', 'shield'];\nvar KEYS = {};\nSPELL_IDS.forEach(function (id) { KEYS[SPELLS[id].key] = id; });\n\n// Every player's keys arrive as 'key-press' events with the pointer's point.\n// `true` takes them from the game's own hotkeys and camera for this match.\nsos.input.listen(SPELL_IDS.map(function (id) { return SPELLS[id].key; }), true);\n\nvar S = sos.storage;\nvar C = TUNING.centre;\n\n// ── What this script keeps outside `sos.storage` ────────────────────────────\n// All of it can be had again from the world or the storage, so a loaded match\n// rebuilds it (`onLoad`): missiles in flight are simply gone after a load.\nvar LIST = null;   // the seats in play, ascending\nvar POS = {};      // seat -> { x, y }, read every fight tick\nvar HP = {};       // seat -> health, kept by the script's own hits and the lava hook\nvar FLY = [];      // missiles in flight\nvar FALL = [];     // meteors on their way down\nvar QUEUE = [];    // big jobs, one a tick\n\nfunction later(fn) { QUEUE.push(fn); }\nfunction seats() {\n  if (!LIST) {\n    LIST = [];\n    for (var k in (S.seats || {})) LIST.push(Number(k));\n    LIST.sort(function (a, b) { return a - b; });\n  }\n  return LIST;\n}\nfunction dist(ax, ay, bx, by) { var dx = ax - bx, dy = ay - by; return Math.sqrt(dx * dx + dy * dy); }\nfunction pt(x, y) { return { k: 'point', x: x, y: y }; }\nfunction run(actions) { if (actions.length > 0) sos.triggers.run(actions); }\nfunction humans() { return seats().filter(function (s) { return S.seats[s].human; }); }\nfunction standing() { return seats().filter(function (s) { return S.seats[s].alive; }); }\nfunction casterOf(s) { return CASTERS[S.seats[s].pick] || CASTERS[0]; }\nfunction label(s) { return 'P' + (s + 1) + ' ' + casterOf(s).name; }\nfunction posOf(s) { return POS[s] || C; }\nfunction spotOf(i, n) {\n  var a = (2 * Math.PI * i) / n;\n  var r = TUNING.startRadius * 0.6;\n  return { x: C.x + Math.cos(a) * r, y: C.y + Math.sin(a) * r };\n}\nfunction secondsLeft(now, until) { return Math.max(0, Math.ceil((until - now) / 20)); }\n\n// ── HUD (trigger UI frames, through sos.triggers.run) ───────────────────────\nvar PANEL = { backgroundColor: '#1c1210', borderColor: '#c4672a', textColor: '#f6e2c4', borderRadius: 6 };\nvar BUTTON = { backgroundColor: '#2a1712', borderColor: '#e08a3c', textColor: '#ffe9c9', borderRadius: 6 };\nfunction frame(id, anchor, x, y, w, h, title, text, extra) {\n  var f = { k: 'createUIFrame', frameId: id, anchor: anchor, x: x, y: y, width: w, height: h, style: PANEL };\n  if (title) f.title = title;\n  if (text) f.text = text;\n  for (var k in (extra || {})) f[k] = extra[k];\n  return f;\n}\nfunction update(id, title, text) {\n  var f = { k: 'updateUIFrame', frameId: id };\n  if (title !== null) f.title = title;\n  if (text !== null) f.text = text;\n  return f;\n}\nfunction destroy(ids) { return ids.map(function (id) { return { k: 'destroyUIFrame', frameId: id }; }); }\n\nfunction topText(now) {\n  if (S.phase === 'pick') return 'Choose your caster: ' + secondsLeft(now, S.until) + ' s';\n  if (S.phase === 'shop') return 'Round ' + (S.round + 1) + ' of ' + TUNING.rounds + ' next. The shop shuts in ' + secondsLeft(now, S.until) + ' s';\n  if (S.phase === 'countdown') return 'Round ' + S.round + ' of ' + TUNING.rounds + ' in ' + secondsLeft(now, S.until);\n  if (S.phase === 'fight') return 'Round ' + S.round + ' of ' + TUNING.rounds + ': ' + standing().length + ' standing. '\n    + (S.radius > 0.6 ? 'The ring falls in ' + secondsLeft(now, S.shrinkAt) + ' s' : 'The platform is gone');\n  if (S.phase === 'roundEnd') return S.banner;\n  return 'The match is over';\n}\n\n// The scoreboard: one frame at the top left, its text a line a seat (frame text\n// keeps line breaks). Kept off the top right, where the HUD lists overlays.\nfunction scoreText() {\n  return seats().map(function (s) {\n    var st = S.seats[s];\n    return label(s) + (st.human ? '' : ' (bot)') + ': ' + st.points + (st.points === 1 ? ' point' : ' points')\n      + (st.kills ? ', ' + st.kills + (st.kills === 1 ? ' kill' : ' kills') : '');\n  }).join('\\n');\n}\nfunction scoreboard() {\n  later(function () { run([frame('score', 'top_left', 12, 96, 330, 44 + 18 * seats().length, 'Points', scoreText())]); });\n}\nfunction scoreRow() { run([update('score', null, scoreText())]); }\n\n// ── The pick ────────────────────────────────────────────────────────────────\nfunction showPick() {\n  var acts = [frame('top', 'top_center', 0, 70, 520, 56, 'Cinder Ring', 'Choose your caster'),\n    frame('pick_title', 'center', 0, -170, 640, 56, 'Choose your caster', 'Every caster plays the same. Their Fireball looks their own.')];\n  CASTERS.forEach(function (c, i) {\n    var col = i % 3, row = Math.floor(i / 3);\n    acts.push(frame('pick_' + i, 'center', (col - 1) * 215, (row - 1) * 70 - 30, 205, 60, c.name, 'Click to play', { clickable: true, style: BUTTON }));\n  });\n  humans().forEach(function (s) { acts.push(frame('pick_me_' + s, 'center', 0, 150, 420, 44, null, 'You have not chosen yet.', { players: [s] })); });\n  run(acts);\n}\nfunction pickFor(s, i) {\n  var st = S.seats[s];\n  if (!st || S.phase !== 'pick' || !(i >= 0 && i < CASTERS.length)) return;\n  st.pick = i;\n  st.picked = true;\n  if (st.human) run([update('pick_me_' + s, null, 'You play the ' + CASTERS[i].name + '. Waiting for the others.')]);\n}\nfunction endPick(now) {\n  S.phase = 'shop';\n  S.until = now + TUNING.shopTicks + 20;\n  seats().forEach(function (s) { if (!S.seats[s].picked) { S.seats[s].pick = sos.rng.int(0, CASTERS.length - 1); S.seats[s].picked = true; } });\n  later(function () {\n    var ids = ['pick_title'];\n    CASTERS.forEach(function (c, i) { ids.push('pick_' + i); });\n    humans().forEach(function (s) { ids.push('pick_me_' + s); });\n    run(destroy(ids));\n  });\n  scoreboard();\n  openShop(now, TUNING.shopTicks);\n}\n\n// ── The shop ────────────────────────────────────────────────────────────────\nfunction priceOf(s, id) {\n  var lvl = S.seats[s].levels[id] || 0;\n  var sp = SPELLS[id];\n  if (lvl >= sp.max) return -1;\n  return lvl === 0 ? sp.cost : sp.upgrade;\n}\nfunction shopLine(s) {\n  var st = S.seats[s];\n  var parts = SPELL_IDS.map(function (id) {\n    var lvl = st.levels[id] || 0;\n    return SPELLS[id].key.toUpperCase() + ' ' + SPELLS[id].name + ' ' + (lvl || '-');\n  });\n  return 'Gold ' + sos.players.resource(s, 'gold') + '. ' + parts.join(', ') + (st.ready ? '. Ready.' : '');\n}\nfunction openShop(now, ticks) {\n  S.phase = 'shop';\n  S.until = now + ticks + 4 + humans().length;\n  seats().forEach(function (s) { S.seats[s].ready = false; if (!S.seats[s].human) later(function () { botShop(s); }); });\n  later(function () {\n    run([frame('shop_title', 'center', 0, -190, 660, 56, 'The spell shop', 'Gold buys a spell, then levels: more harm, less wait. Click Ready when you are done.'),\n      frame('shop_ready', 'center', 165, 12, 320, 58, 'Ready', 'Start the round when everyone is ready', { clickable: true, style: BUTTON })]);\n  });\n  [[0, 1, 2], [3, 4]].forEach(function (group) {\n    later(function () {\n      run(group.map(function (i) {\n        var id = SPELL_IDS[i], sp = SPELLS[id];\n        var price = sp.cost ? sp.cost + ' gold, +' + sp.upgrade + ' a level' : 'yours; +' + sp.upgrade + ' a level';\n        return frame('buy_' + id, 'center', i % 2 === 0 ? -165 : 165, Math.floor(i / 2) * 66 - 120, 320, 58,\n          sp.key.toUpperCase() + '  ' + sp.name + ' (to level ' + sp.max + ')', sp.info + '. ' + price, { clickable: true, style: BUTTON });\n      }));\n    });\n  });\n  humans().forEach(function (s) { later(function () { run([frame('shop_me_' + s, 'center', 0, 90, 660, 50, null, shopLine(s), { players: [s] })]); }); });\n}\nfunction buy(s, id) {\n  var st = S.seats[s];\n  if (!st || S.phase !== 'shop' || !SPELLS[id]) return false;\n  var price = priceOf(s, id);\n  var gold = sos.players.resource(s, 'gold');\n  if (price < 0 || gold < price) return false;\n  sos.players.setResource(s, 'gold', gold - price);\n  st.levels[id] = (st.levels[id] || 0) + 1;\n  if (st.human) run([update('shop_me_' + s, null, shopLine(s))]);\n  return true;\n}\nfunction closeShop(now) {\n  S.phase = 'countdown';\n  S.until = now + TUNING.countdownTicks;\n  S.round += 1;\n  S.radius = TUNING.startRadius;\n  FLY = []; FALL = [];\n  later(function () {\n    var ids = ['shop_title', 'shop_ready'];\n    SPELL_IDS.forEach(function (id) { ids.push('buy_' + id); });\n    humans().forEach(function (s) { ids.push('shop_me_' + s); });\n    run(destroy(ids));\n  });\n  later(function () { sos.terrain.setRing(C, TUNING.startRadius, 'dirt'); sos.overlay.clear('ring'); });\n  var list = seats();\n  list.forEach(function (s, i) { later(function () { spawn(s, spotOf(i, list.length)); }); });\n}\nfunction spawn(s, at) {\n  var st = S.seats[s];\n  if (st.unit) sos.units.remove(st.unit);\n  var ids = sos.units.spawn(casterOf(s).unit, 1, s, at, Math.atan2(C.y - at.y, C.x - at.x));\n  st.unit = ids.length ? ids[0] : 0;\n  st.alive = st.unit > 0;\n  st.strain = 0; st.cd = {}; st.shield = 0; st.lastBy = -1; st.lastAt = -1000; st.botAt = 0;\n  POS[s] = { x: at.x, y: at.y };\n  HP[s] = 100;\n  if (st.human && st.unit) {\n    sos.units.select(s, [st.unit]);\n    run([frame('cd_' + s, 'bottom_center', 0, 210, 640, 40, null, 'Q W E R F cast at the pointer. Right-click to walk.', { players: [s] })]);\n  }\n}\n\n// ── A round ─────────────────────────────────────────────────────────────────\nfunction startFight(now) {\n  S.phase = 'fight';\n  scheduleShrink(now);\n  run([{ k: 'showText', players: null, text: 'Fight! Right-click to walk; Q W E R F cast at the pointer.', durationTicks: 80 }]);\n}\nfunction scheduleShrink(now) {\n  S.shrinkAt = now + TUNING.shrinkBaseTicks + TUNING.shrinkPerAliveTicks * standing().length;\n  S.warned = false;\n}\n// Paint the ring that falls next on an overlay layer, in four bands of rows (a job each).\nfunction warnRing() {\n  S.warned = true;\n  var r = S.radius;\n  var x0 = Math.floor(C.x - r), y0 = Math.floor(C.y - r), w = Math.ceil(C.x + r) - x0 + 1;\n  var o2 = r * r, i2 = (r - 1) * (r - 1);\n  var band = Math.ceil(w / 4);\n  for (var b = 0; b < 4; b++) {\n    (function (top) {\n      later(function () {\n        var values = [];\n        for (var y = top; y < Math.min(y0 + w, top + band); y++) {\n          for (var x = x0; x < x0 + w; x++) {\n            var dx = x + 0.5 - C.x, dy = y + 0.5 - C.y, d2 = dx * dx + dy * dy;\n            values.push(d2 < o2 && d2 >= i2 ? 1 : null);\n          }\n        }\n        if (values.length) sos.overlay.paint('ring', { x: x0, y: top }, w, values);\n      });\n    })(y0 + b * band);\n  }\n}\nfunction shrink(now) {\n  sos.terrain.setRing(C, S.radius, 'shallowLava', Math.max(0, S.radius - 1));\n  later(function () { sos.overlay.clear('ring'); });\n  S.radius -= 1;\n  if (S.radius > 0.6) scheduleShrink(now);\n}\nfunction out(s, now) {\n  var st = S.seats[s];\n  if (!st.alive) return;\n  st.alive = false;\n  var by = st.lastBy;\n  if (by >= 0 && by !== s && S.seats[by] && now - st.lastAt <= TUNING.killCreditTicks) {\n    S.seats[by].points += 1;\n    S.seats[by].kills += 1;\n    run([{ k: 'showText', players: null, text: label(by) + ' threw ' + label(s) + ' out.', durationTicks: 60 }]);\n    later(scoreRow);\n  }\n}\nfunction endRound(now) {\n  var left = standing();\n  if (left.length === 1) {\n    S.seats[left[0]].points += 1;\n    S.banner = 'Round ' + S.round + ' to ' + label(left[0]) + '.';\n    later(scoreRow);\n  } else {\n    S.banner = 'Round ' + S.round + ': nobody left standing.';\n  }\n  S.phase = 'roundEnd';\n  S.until = now + TUNING.roundEndTicks;\n  var flying = FLY;\n  FLY = []; FALL = [];\n  later(function () { flying.forEach(function (m) { sos.effects.stop(m.fx); }); });\n  seats().forEach(function (s) { sos.players.addResource(s, 'gold', TUNING.goldPerRound); });\n  later(function () { run(destroy(humans().map(function (s) { return 'cd_' + s; }))); });\n  later(function () { run([update('top', null, S.banner)]); });\n}\nfunction endMatch() {\n  S.phase = 'done';\n  var best = -1, winners = [];\n  seats().forEach(function (s) {\n    var p = S.seats[s].points;\n    if (p > best) { best = p; winners = [s]; } else if (p === best) winners.push(s);\n  });\n  seats().forEach(function (s) { later(function () { if (S.seats[s].unit) sos.units.remove(S.seats[s].unit); S.seats[s].unit = 0; }); });\n  var names = winners.map(label).join(' and ');\n  later(function () {\n    run([frame('results', 'center', 0, -40, 560, 110, winners.length > 1 ? 'A shared win' : 'Victory',\n      names + ' with ' + best + (best === 1 ? ' point' : ' points') + ' after ' + S.round + ' rounds.'),\n      update('top', null, 'The match is over')]);\n  });\n  later(function () {\n    winners.forEach(function (s) { sos.game.victory(s); });\n    seats().forEach(function (s) { if (winners.indexOf(s) < 0) sos.game.defeat(s); });\n  });\n}\n\n// ── Casting ─────────────────────────────────────────────────────────────────\nfunction ready(s, id, now) {\n  var st = S.seats[s];\n  return st.alive && (st.levels[id] || 0) > 0 && now >= (st.cd[id] || 0);\n}\nfunction cast(s, id, x, y, now) {\n  var st = S.seats[s];\n  if (S.phase !== 'fight' || !ready(s, id, now)) return;\n  var sp = SPELLS[id];\n  var lvl = st.levels[id];\n  st.cd[id] = now + sp.cd[lvl - 1];\n  var me = posOf(s);\n  var dx = x - me.x, dy = y - me.y;\n  var d = Math.sqrt(dx * dx + dy * dy) || 1;\n  var ux = dx / d, uy = dy / d;\n  if (id === 'fireball') {\n    var ticks = Math.round(sp.range / sp.speed);\n    var from = { x: me.x + ux * 0.6, y: me.y + uy * 0.6 };\n    var to = { x: from.x + ux * sp.range, y: from.y + uy * sp.range };\n    var fx = sos.effects.missile(casterOf(s).bolt, from, to, ticks);\n    FLY.push({ fx: fx, seat: s, x: from.x, y: from.y, vx: ux * sp.speed, vy: uy * sp.speed, left: ticks,\n      r: sp.radius, dmg: sp.dmg[lvl - 1], knock: sp.knock });\n  } else if (id === 'lightning') {\n    // Instant: the first enemy within `width` of the line.\n    var best = -1, bestT = sp.range;\n    seats().forEach(function (o) {\n      if (o === s || !S.seats[o].alive) return;\n      var p = posOf(o), px = p.x - me.x, py = p.y - me.y;\n      var t = px * ux + py * uy;\n      if (t > 0 && t < bestT && Math.abs(px * uy - py * ux) < sp.width) { best = o; bestT = t; }\n    });\n    var end = { x: me.x + ux * bestT, y: me.y + uy * bestT };\n    sos.effects.missile(sp.look, me, end, 3);\n    if (best >= 0) {\n      sos.effects.play(sp.hit, end);\n      hit(s, best, sp.dmg[lvl - 1], sp.knock, ux, uy, now);\n    }\n  } else if (id === 'meteor') {\n    var reach = Math.min(d, sp.range);\n    var at = { x: me.x + ux * reach, y: me.y + uy * reach };\n    FALL.push({ seat: s, x: at.x, y: at.y, at: now + sp.delay, dmg: sp.dmg[lvl - 1] });\n    sos.effects.play(sp.warn, at);\n  } else if (id === 'blink') {\n    var hop = Math.min(d, sp.range[lvl - 1]);\n    var dest = { x: me.x + ux * hop, y: me.y + uy * hop };\n    // Never into a pillar or the deep lava: step back along the line until clear.\n    for (var tries = 0; tries < 6; tries++) {\n      var ground = sos.terrain.get(dest);\n      if (ground !== 'rock' && ground !== 'lava' && ground !== '') break;\n      hop -= 1;\n      dest = { x: me.x + ux * hop, y: me.y + uy * hop };\n    }\n    sos.effects.play(sp.look, me);\n    sos.units.order([st.unit], 'stop', s);\n    sos.units.move(st.unit, dest);\n    sos.effects.play(sp.look, dest);\n    POS[s] = dest;\n  } else if (id === 'shield') {\n    st.shield = now + sp.ticks[lvl - 1];\n    sos.effects.play(sp.look, me);\n  }\n}\nfunction hit(by, s, dmg, knock, ux, uy, now) {\n  var st = S.seats[s];\n  if (!st.alive) return;\n  if (now < st.shield) { sos.effects.play(SPELLS.shield.look, posOf(s)); return; }\n  st.strain += dmg;\n  st.lastBy = by;\n  st.lastAt = now;\n  HP[s] = (HP[s] === undefined ? 100 : HP[s]) - dmg;\n  if (HP[s] <= 0) { sos.units.kill(st.unit); out(s, now); return; }\n  sos.units.setHp(st.unit, HP[s]);\n  // The shove: a hit throws 1 + strain/100 times as far as the first one did.\n  var v = knock * (1 + st.strain / 100);\n  run([{ k: 'applyImpulse', unit: st.unit, vx: ux * v, vy: uy * v, friction: TUNING.friction }]);\n}\nfunction burst(m) {\n  sos.effects.stop(m.fx);\n  sos.effects.play(SPELLS.fireball.hit, { x: m.x, y: m.y });\n}\n\n// ── Each tick of a fight ────────────────────────────────────────────────────\nfunction readCasters(now) {\n  var list = standing();\n  if (!list.length) return;\n  var at = sos.units.positions(list.map(function (s) { return S.seats[s].unit; }));\n  list.forEach(function (s, i) {\n    if (at[i * 2] < 0) { out(s, now); return; }\n    POS[s] = { x: at[i * 2], y: at[i * 2 + 1] };\n  });\n}\nfunction stepMissiles(now) {\n  var keep = [];\n  for (var i = 0; i < FLY.length; i++) {\n    var m = FLY[i];\n    if (m.gone) continue;\n    m.x += m.vx; m.y += m.vy; m.left -= 1;\n    if (sos.terrain.get({ x: m.x, y: m.y }) === 'rock') { burst(m); continue; }\n    var struck = -1;\n    var list = seats();\n    for (var k = 0; k < list.length && struck < 0; k++) {\n      var s = list[k];\n      if (s === m.seat || !S.seats[s].alive) continue;\n      var p = posOf(s);\n      if (dist(p.x, p.y, m.x, m.y) < m.r + 0.45) struck = s;\n    }\n    if (struck >= 0) {\n      burst(m);\n      var len = Math.sqrt(m.vx * m.vx + m.vy * m.vy) || 1;\n      hit(m.seat, struck, m.dmg, m.knock, m.vx / len, m.vy / len, now);\n      continue;\n    }\n    // Two enemy missiles that touch both burst.\n    for (var j = i + 1; j < FLY.length; j++) {\n      var o = FLY[j];\n      if (!o.gone && o.seat !== m.seat && dist(o.x, o.y, m.x, m.y) < m.r + o.r) { burst(o); o.gone = true; m.gone = true; break; }\n    }\n    if (m.gone) { burst(m); continue; }\n    if (m.left > 0) keep.push(m);\n  }\n  FLY = keep;\n}\nfunction stepMeteors(now) {\n  var keep = [];\n  FALL.forEach(function (m) {\n    if (now < m.at) { keep.push(m); return; }\n    var sp = SPELLS.meteor;\n    sos.effects.play(sp.hit, { x: m.x, y: m.y });\n    seats().forEach(function (s) {\n      if (s === m.seat || !S.seats[s].alive) return;\n      var p = posOf(s), d = dist(p.x, p.y, m.x, m.y);\n      if (d >= sp.radius) return;\n      var ux = d > 0.01 ? (p.x - m.x) / d : 1, uy = d > 0.01 ? (p.y - m.y) / d : 0;\n      hit(m.seat, s, m.dmg, sp.knock * (1 - d / (sp.radius * 2)), ux, uy, now);\n    });\n  });\n  FALL = keep;\n}\nfunction cooldownLine(s, now) {\n  var st = S.seats[s];\n  return SPELL_IDS.filter(function (id) { return (st.levels[id] || 0) > 0; }).map(function (id) {\n    var wait = secondsLeft(now, st.cd[id] || 0);\n    return SPELLS[id].key.toUpperCase() + ' ' + SPELLS[id].name + (wait > 0 ? ' ' + wait + 's' : ' ready');\n  }).join('   ') + (now < st.shield ? '   WARDED' : '') + '   Strain ' + st.strain;\n}\n\n// ── Bots (seats nobody plays) ───────────────────────────────────────────────\nfunction botShop(s) {\n  var order = ['lightning', 'meteor', 'blink', 'fireball', 'shield'];\n  for (var i = order.length - 1; i > 0; i--) { var j = sos.rng.int(0, i); var t = order[i]; order[i] = order[j]; order[j] = t; }\n  for (var pass = 0; pass < 3; pass++) order.forEach(function (id) { buy(s, id); });\n}\nfunction botThink(s, now) {\n  var st = S.seats[s];\n  if (!st.alive) return;\n  var me = posOf(s);\n  var dc = dist(me.x, me.y, C.x, C.y);\n  var r = Math.max(1, S.radius);\n  if (dc > r - 1.2) {\n    var ax = C.x + (me.x - C.x) / (dc || 1) * r * 0.35, ay = C.y + (me.y - C.y) / (dc || 1) * r * 0.35;\n    if (dc > r + 0.3 && ready(s, 'blink', now)) cast(s, 'blink', ax, ay, now);\n    else sos.units.order([st.unit], 'move', s, { x: ax, y: ay });\n    st.botAt = now;\n    return;\n  }\n  var target = -1, td = 99;\n  seats().forEach(function (o) {\n    if (o === s || !S.seats[o].alive) return;\n    var p = posOf(o), d = dist(me.x, me.y, p.x, p.y);\n    if (d < td) { td = d; target = o; }\n  });\n  if (target < 0) return;\n  var tp = posOf(target);\n  var aimX = tp.x + (sos.rng.float() - 0.5) * 1.2, aimY = tp.y + (sos.rng.float() - 0.5) * 1.2;\n  if (ready(s, 'shield', now) && FLY.some(function (m) { return m.seat !== s && dist(m.x, m.y, me.x, me.y) < 2.2; })) cast(s, 'shield', me.x, me.y, now);\n  else if (td < 6.5 && ready(s, 'lightning', now)) cast(s, 'lightning', aimX, aimY, now);\n  else if (td < 9 && ready(s, 'meteor', now) && sos.rng.chance(0.35)) cast(s, 'meteor', aimX, aimY, now);\n  else if (td < 10.5 && ready(s, 'fireball', now)) cast(s, 'fireball', aimX, aimY, now);\n  else if (now - st.botAt > 50 && sos.rng.chance(0.3)) {\n    var a = sos.rng.float() * Math.PI * 2, rr = sos.rng.float() * r * 0.55;\n    sos.units.order([st.unit], 'move', s, { x: C.x + Math.cos(a) * rr, y: C.y + Math.sin(a) * rr });\n    st.botAt = now;\n  }\n}\n\n// ── The match ───────────────────────────────────────────────────────────────\nsos.lifecycle.onInit(function (e) {\n  S.seats = {};\n  for (var s = 0; s < 8; s++) {\n    var info = sos.players.get(s);\n    if (!info) continue;\n    S.seats[s] = { human: info.human, pick: -1, picked: false, levels: { fireball: 1 }, points: 0, kills: 0,\n      unit: 0, alive: false, strain: 0, cd: {}, shield: 0, lastBy: -1, lastAt: -1000, botAt: 0, ready: false };\n    sos.players.setResource(s, 'gold', TUNING.startGold);\n  }\n  LIST = null;\n  S.round = 0;\n  S.radius = TUNING.startRadius;\n  S.banner = '';\n  S.phase = 'pick';\n  S.until = e.tick + TUNING.pickTicks;\n  sos.overlay.define('ring', { name: 'The next ring to fall', legend: 'These tiles turn to lava next',\n    colors: ['#ff4d1a', '#ffc46b'], min: 0, max: 1, opacity: 55, smooth: false, show: true });\n  run([{ k: 'cameraPan', players: null, to: pt(C.x, C.y), durationTicks: 0 }, { k: 'cameraLock', players: null, at: pt(C.x, C.y) }]);\n  seats().forEach(function (s2) { if (!S.seats[s2].human) pickFor(s2, sos.rng.int(0, CASTERS.length - 1)); });\n  showPick();\n});\n\n// A loaded match: what the script keeps outside storage comes back from it.\nsos.lifecycle.onLoad(function () {\n  LIST = null; POS = {}; HP = {}; FLY = []; FALL = []; QUEUE = [];\n  seats().forEach(function (s) { HP[s] = 100; });\n});\n\nsos.events.onEvent(function (e) {\n  var now = sos.time.tick();\n  var d = e.data;\n  if (e.name === 'key-press') {\n    if (d.down && d.x !== null && KEYS[d.key] && S.seats[d.seat]) cast(d.seat, KEYS[d.key], d.x, d.y, now);\n  } else if (e.name === 'ui-click') {\n    var id = d.frameId;\n    if (id.indexOf('pick_') === 0 && id.indexOf('pick_me_') !== 0) pickFor(d.seat, Number(id.slice(5)));\n    else if (id.indexOf('buy_') === 0) buy(d.seat, id.slice(4));\n    else if (id === 'shop_ready' && S.phase === 'shop' && S.seats[d.seat]) {\n      S.seats[d.seat].ready = true;\n      run([update('shop_me_' + d.seat, null, shopLine(d.seat))]);\n    }\n  } else if (e.name === 'entity-died') {\n    seats().forEach(function (s) { if (S.seats[s].unit === d.entityId) out(s, now); });\n  }\n});\n\n// Lava is the engine's own (shallow lava burns whoever wades it); this softens\n// it and counts half of each burn toward the strain.\nsos.hooks.onDamage(function (e) {\n  if (e.attacker !== 0) return;\n  var hurt = e.amount * TUNING.lavaDamageScale;\n  seats().forEach(function (s) {\n    if (S.seats[s].unit !== e.target) return;\n    S.seats[s].strain += Math.round(hurt / 2);\n    if (HP[s] !== undefined) HP[s] -= hurt;\n  });\n  return hurt;\n});\n\nsos.lifecycle.onTick(function (e) {\n  var now = e.tick;\n  if (QUEUE.length) QUEUE.shift()();\n  if (S.phase === 'pick') {\n    var waiting = humans().some(function (s) { return !S.seats[s].picked; });\n    if (!waiting || now >= S.until) endPick(now);\n  } else if (S.phase === 'shop') {\n    var shopping = humans().some(function (s) { return !S.seats[s].ready; });\n    if ((!shopping || now >= S.until) && !QUEUE.length) closeShop(now);\n  } else if (S.phase === 'countdown') {\n    if (now >= S.until && !QUEUE.length) startFight(now);\n  } else if (S.phase === 'fight') {\n    readCasters(now);\n    stepMissiles(now);\n    stepMeteors(now);\n    if (S.radius > 0.6) {\n      if (!S.warned && now >= S.shrinkAt - TUNING.warnTicks) warnRing();\n      else if (now >= S.shrinkAt && !QUEUE.length) shrink(now);\n    }\n    var list = seats();\n    for (var i = 0; i < list.length; i++) {\n      var s = list[i];\n      if (!S.seats[s].human && (now + s) % TUNING.botThinkTicks === 0) botThink(s, now);\n    }\n    if (standing().length <= 1) endRound(now);\n  } else if (S.phase === 'roundEnd') {\n    if (now >= S.until && !QUEUE.length) {\n      if (S.round >= TUNING.rounds) endMatch();\n      else openShop(now, TUNING.shopTicksLater);\n    }\n  }\n  // The HUD: the top line once a second, each player's spells once a second, staggered.\n  if (S.phase !== 'done') {\n    if (now % 20 === 0) run([update('top', null, topText(now))]);\n    if (S.phase === 'fight' || S.phase === 'countdown') {\n      humans().forEach(function (s) {\n        if (S.seats[s].unit && now % 20 === (s * 2 + 7) % 20) run([update('cd_' + s, null, cooldownLine(s, now))]);\n      });\n    }\n  }\n});\n","apiLevel":6}],"defOverrides":{"units":[{"id":"cinder_human","baseId":"human_wizard","patch":{"name":"Sunward Cleric","description":"A cleric of the dawn. Their Fireball is holy light.","hp":100,"armor":0,"damage":0,"range":0,"canAttackGround":false,"canAttackAir":false,"speed":2.6,"sightRadius":30,"mana":0,"spells":[],"populationCost":0,"cost":{"gold":0,"lumber":0,"stone":0,"oil":0}}},{"id":"cinder_dwarf","baseId":"dwarf_runesmith","patch":{"name":"Forge Runecaller","description":"A runesmith with a hammer of embers. Their Fireball is a struck rune.","hp":100,"armor":0,"damage":0,"range":0,"canAttackGround":false,"canAttackAir":false,"speed":2.6,"sightRadius":30,"mana":0,"spells":[],"populationCost":0,"cost":{"gold":0,"lumber":0,"stone":0,"oil":0}}},{"id":"cinder_high_elf","baseId":"high_elf_mage","patch":{"name":"Starfire Magus","description":"A magus of the high towers. Their Fireball is starfire.","hp":100,"armor":0,"damage":0,"range":0,"canAttackGround":false,"canAttackAir":false,"speed":2.6,"sightRadius":30,"mana":0,"spells":[],"populationCost":0,"cost":{"gold":0,"lumber":0,"stone":0,"oil":0}}},{"id":"cinder_dark_elf","baseId":"dark_elf_mage","patch":{"name":"Veil Sorceress","description":"A seer of the deep. Their Fireball is void.","hp":100,"armor":0,"damage":0,"range":0,"canAttackGround":false,"canAttackAir":false,"speed":2.6,"sightRadius":30,"mana":0,"spells":[],"populationCost":0,"cost":{"gold":0,"lumber":0,"stone":0,"oil":0}}},{"id":"cinder_undead","baseId":"undead_necromancer","patch":{"name":"Grave Necromancer","description":"A necromancer in grave silk. Their Fireball is a soul.","hp":100,"armor":0,"damage":0,"range":0,"canAttackGround":false,"canAttackAir":false,"speed":2.6,"sightRadius":30,"mana":0,"spells":[],"populationCost":0,"cost":{"gold":0,"lumber":0,"stone":0,"oil":0}}},{"id":"cinder_goblin","baseId":"goblin_shaman","patch":{"name":"Bog Shaman","description":"A goblin shaman with a crooked staff. Their Fireball is fire.","hp":100,"armor":0,"damage":0,"range":0,"canAttackGround":false,"canAttackAir":false,"speed":2.6,"sightRadius":30,"mana":0,"spells":[],"populationCost":0,"cost":{"gold":0,"lumber":0,"stone":0,"oil":0}}},{"id":"cinder_wood_elf","baseId":"wood_elf_mage","patch":{"name":"Grove Mage","description":"A singer of the old woods. Their Fireball is a starlit arrow.","hp":100,"armor":0,"damage":0,"range":0,"canAttackGround":false,"canAttackAir":false,"speed":2.6,"sightRadius":30,"mana":0,"spells":[],"populationCost":0,"cost":{"gold":0,"lumber":0,"stone":0,"oil":0}}},{"id":"cinder_lizardmen","baseId":"lizardmen_skink_priest","patch":{"name":"Sun Priest","description":"A skink oracle of the sun. Their Fireball is solar.","hp":100,"armor":0,"damage":0,"range":0,"canAttackGround":false,"canAttackAir":false,"speed":2.6,"sightRadius":30,"mana":0,"spells":[],"populationCost":0,"cost":{"gold":0,"lumber":0,"stone":0,"oil":0}}},{"id":"cinder_ratmen","baseId":"ratmen_warlock","patch":{"name":"Warp Warlock","description":"A ratman engineer. Their Fireball is plague.","hp":100,"armor":0,"damage":0,"range":0,"canAttackGround":false,"canAttackAir":false,"speed":2.6,"sightRadius":30,"mana":0,"spells":[],"populationCost":0,"cost":{"gold":0,"lumber":0,"stone":0,"oil":0}}}]}}