Map Editor Documentation

Build a city builder

Turn the city on for your own map, give it your own numbers, drive it from scripts and triggers, paint map layers and reshape the land. Cities of Stone is built this way.

Genres · Page 47 of 49

Cities of Stone is a map. Its roads, zones, services, peoples, districts and seasons all come from one setting in the map file, rules.city, and everything below works on any map that sets it. You can make your own city builder from the same parts: a dwarven mountain hold where water is dear, a goblin junk town where the works smoke and burn, a farming valley, or a volcano town where the mountain decides what gets built.

Three of the tools on this page work on every map, not just cities: script overlays (your own map layers), runtime terrain (changing the ground during play) and the events and timers that drive them. A tower defence can paint its kill zones; a survival map can flood a valley.

Start from Cities of Stone

Download The Concord of Aethermere, the map the game itself plays, and open it in the Forge with File → Import Map JSON…. Its city rules come with it. Save it as your own and change anything.

To start from a blank map instead, open the Players module in the Forge. Under Rules there is a City panel: switch it on, pick the setting and how cities are founded, set the starting gold, and give the city your own numbers. Switching the city on also turns standard victory and the automatic starting base off, because a city builder founds its own hall and has no last-team-standing ending; turn either back on in Rules if your map wants it.

Switch a city on: rules.city

{
  "rules": {
    "standardVictory": false,
    "noStartingUnits": true,
    "city": {
      "setting": "golden_age",
      "founding": "choose",
      "startingGold": 5000,
      "startingTaxPercent": 9
    }
  }
}
FieldWhat it does
settinggolden_age (peaceful), frontier (raids, lairs, disasters), rival_crowns (rival cities race to a Capital), grand_concord (co-op: one great city)
foundingchoose: each player founds their city where they like. placed: the map places the town halls
startingGold, startingTaxPercentEach city's starting gold and tax rate
combatWhether armies exist. Absent: the setting's own choice
raids, disastersThe Frontier's raids and its storms, floods and quakes. Set false for a quiet frontier
seasonsfalse turns the lean season and granaries off
unlocks, startingLoreLore items every city holds from the start, and Lore to spend
cityNameThe name a single player's city starts with
tuningThe map's own numbers (next section)

Your own numbers: rules.city.tuning

A map may change a chosen set of the city's figures for its own matches. Any field you leave out keeps the game's own number, and nothing you set reaches any other map: the numbers are resolved for your match alone.

{
  "city": {
    "setting": "golden_age",
    "founding": "choose",
    "tuning": {
      "services": {
        "water": { "cost": 400, "radius": 6, "upkeep": 9 },
        "fire": { "cost": 150, "buildSteps": 4 }
      },
      "needs": {
        "residential": [[], ["water", "safety"], ["water", "safety", "faith", "food"]]
      },
      "taxes": { "maxPercent": 30, "perResident": 4 },
      "demand": { "baseJobs": 80 },
      "tiers": { "village": 120, "town": 500 },
      "seasons": { "yearSteps": 480, "leanSteps": 90, "leanFoodPercent": 40 },
      "startingHallTier": 2,
      "unlocks": { "density_medium": 1, "service_fire": 1 },
      "peoples": ["dwarf", "human"]
    }
  }
}
GroupKeysNotes
servicesper service (water, safety, faith, fire, health, learning, entertainment, market, park, academy): cost (0 to 100,000), upkeep (gold a minute, 0 to 10,000), radius (reach in tiles, 1 to 64), buildSteps (seconds to build, 1 to 600), jobs (0 to 500)A park's radius is its beauty
needsper zone (residential, commercial, industrial, farm): a list of three lists, the needs of levels 1, 2 and 3A need is a service's name, or food. A lot short of a need of its level declines; one short of the next level's stays where it is
taxesperResident, perCommercialJob, perIndustrialJob (gold a minute at a 100% rate, 0 to 100), maxPercent (the tax ceiling, 1 to 100)
demandworkforcePercent, baseJobs, commercialJobsPer100Pop, baseCommercialJobs, industrialJobsPer100Workers, baseIndustrialJobs, neutralTaxPercent, perTaxPercentbaseJobs is the work the outside world offers before the city has any: raise it for a faster start
tiersvillage, town, city, capital, concord: the population each tier starts atThey must still rise
seasonsyearSteps (seconds in a year, 120 to 36,000), leanSteps (the lean season, at most half the year), leanFoodPercent (what farms yield in it, 0 to 100)Switch seasons off with rules.city.seasons: false
startingHallTier1 a town hall, 2 a keep, 3 a castleThe hall a city founds with on a choose map. On a placed map, place the hall you want
unlockstool id to hall tier: 0 (always open), 1, 2 or 3Only tools the halls open for free (zone_farm, density_medium, service_fire...). An item Lore prices, such as avenues, is handed out with rules.city.unlocks instead
peoplesthe peoples who may come to live: human, dwarf, goblin, ratmen, lizardmen, high_elf, wood_elf, dark_elf, undeadFounders always may. Absent: all of them

Every number must be a whole number in its range. A value the city cannot use is ignored and the usual number plays; the Forge's City panel and Validation (F10) both list each one as invalid-city-tuning, so nothing fails silently. Only maps read on this build understand tuning; an older build plays the map with the usual numbers.

Read and drive the city from a script: sos.city

Scripts reach the city through sos.city. Writes are the very commands the city's own HUD sends, so the city checks them the same way, the seat pays the same prices, and a refusal is the same refusal: the call returns false (or { ok: false, reason }) and nothing happens. Without city rules every read returns null and every write false.

CallReturns
sos.city.stats(seat)population, jobs, lots, tier and its name, hall tier, tax, income and upkeep a minute, demand per zone, coverage per service, food, problems (blight, crime, burning, burnt, plague), harmony, wants, goods in stock, Lore
sos.city.coverageAt({x, y})each service's reach at a tile, 0 to 255
sos.city.lotsIn(from, to)the lots in a rectangle: id, seat, position and size, zone or service, level, people, residents, jobs, land value, burning, burnt, sick, abandoned
sos.city.lot(id), sos.city.districts(seat)one lot; a seat's districts
sos.city.zone(seat, from, to, zone, density?)zone a rectangle: residential, commercial, industrial, farm, or none to clear
sos.city.placeService(seat, service, {x, y})place a service with its top-left at a tile beside the seat's road
sos.city.setTax(seat, percent)set the tax, held to the map's ceiling
sos.city.command(seat, command)any other city command, such as { type: 'city_road', x0, y0, x1, y1, tier }; returns { ok, reason }
sos.city.raiseDisaster(lotId, 'fire' or 'plague')set a built lot alight, or start a plague in a home. The city's rules then run: a fire watch puts the fire out, healers cure the plague

sos.events.onEvent hears the city: city-lot-built (a lot finished: lotId, playerId, zone, service), city-lot-levelled, city-lot-grown (a new plot breaking ground), city-lot-abandoned, city-fire (what: started, spread, out, burnt), city-plague, city-peoples (a people arrived, rioted or left), city-tier-up, city-district-founded, city-season (what: warning, lean, plenty) and city-contest (Rival Crowns or the Grand Concord decided).

Trigger nodes

The same city is open to triggers. Events: cityLotBuilt (event value is the lot id, event text its zone or service), cityLotLevelled, cityLotBurnt, cityTierReached and cityLeanSeason. Conditions and values: cityStat (population, tier, money, demand, food, harmony, problems) and cityCoverageAt. Actions: cityZone (a region), cityPlaceService, citySetTax and cityDisaster. Every one is in the Trigger Reference.

Draw your own layers: overlays

A map can paint its own layer over the ground: a number per tile, drawn through a colour ramp, with a legend line. Players switch layers on and off in the Overlays frame, top right of the screen; it appears as soon as a map defines a layer that player may see. Overlays work on every map.

  • sos.overlay.define(id, { name, legend, colors, min, max, opacity, smooth, seats, show }): 2 to 6 colours from low to high ('#rrggbb'), the value drawn as the first colour (min, default 0) and the last (max, default 100). seats limits who may see it; show turns it on for everyone now. Up to 8 layers.
  • sos.overlay.paint(id, {x, y}, width, values): a block of values, row by row. A value that is not a number leaves its tile clear.
  • sos.overlay.fill(id, from, to, value), clear(id), remove(id), show(id, on).
  • Triggers: overlayDefine, overlayFill (a region) and overlayClear.

Overlays are for players to read and nothing in the game reads them back, so they never change a match. They are not saved: repaint them from a timer if your map needs them after a load. They draw in the 3D view.

Change the land: runtime terrain

sos.terrain.set(from, to, kind) and the setTerrain trigger action turn a rectangle of tiles (at most 4,096) to grass, dirt or shallowWater: a flood, a drained marsh, a scorched field. Pathing, the minimap and the ground follow at once, and a city sees the new ground on its next step. It returns how many tiles changed.

To keep every match whole, some tiles never change: trees, ore, rock, deep water, lava and cliffs. Land and water never swap under a building, a city lot or a ship, or beside lava. Cliff height, ramps and pathing blockers cannot change during play; see Terrain and height.

Example: a volcano town

A city at the foot of a volcano. The script paints a danger layer round the vent, and every 90 seconds the mountain scorches the ground and sets the nearest homes alight. Players learn to keep a fire watch near the vent and to build their best streets out of the red.

{
  "rules": {
    "standardVictory": false,
    "noStartingUnits": true,
    "city": {
      "setting": "golden_age",
      "founding": "choose",
      "startingGold": 6000,
      "tuning": { "services": { "fire": { "cost": 150, "radius": 18 } } }
    }
  }
}
// Volcano town: a danger layer round the vent, and eruptions every 90 seconds.
var VENT = { x: 40, y: 30 };   // the crater, in tiles
var REACH = 12;                // how far its heat reaches
 
sos.lifecycle.onInit(function () {
  sos.overlay.define('danger', {
    name: 'Volcano danger',
    legend: 'Red: lava bombs land here. Green: safe to build.',
    colors: ['#3fa34d', '#f2c14e', '#d7263d'],
    min: 0, max: REACH, show: true,
  });
  // Hotter nearer the vent: REACH at the crater, 0 at the edge of its reach.
  var values = [];
  for (var dy = -REACH; dy <= REACH; dy++) {
    for (var dx = -REACH; dx <= REACH; dx++) {
      values.push(REACH - Math.max(Math.abs(dx), Math.abs(dy)));
    }
  }
  sos.overlay.paint('danger', { x: VENT.x - REACH, y: VENT.y - REACH }, REACH * 2 + 1, values);
  sos.storage.eruptions = 0;
  sos.timers.repeat('eruption', 20 * 90);
});
 
sos.lifecycle.onTimer(function (event) {
  if (event.name !== 'eruption') return;
  sos.storage.eruptions += 1;
  // The crater's rim scorches to dirt.
  sos.terrain.set({ x: VENT.x - 3, y: VENT.y - 3 }, { x: VENT.x + 3, y: VENT.y + 3 }, 'dirt');
  // Lava bombs: up to three built lots within reach catch fire.
  var lots = sos.city.lotsIn({ x: VENT.x - REACH, y: VENT.y - REACH }, { x: VENT.x + REACH, y: VENT.y + REACH }) || [];
  var struck = 0;
  for (var i = 0; i < lots.length && struck < 3; i++) {
    var lot = lots[i];
    if (lot.zone !== 'none' && lot.level >= 1 && !lot.burning && sos.city.raiseDisaster(lot.id, 'fire')) struck += 1;
  }
});
 
sos.events.onEvent(function (event) {
  // A city that reaches a town is paid for living with the mountain.
  if (event.name === 'city-tier-up' && event.data.tier === 2) {
    sos.players.addResource(event.data.playerId, 'gold', 500);
  }
});

Every call in it is metered like any script call. The danger layer is painted once, so the eruption timer costs only a lot read and up to three fires.

Three more ideas

  • A dwarven mountain hold. peoples: ["dwarf", "human"], water dear and short-reaching (services.water), the fire watch cheap, and stone works unlocked early (unlocks). A mountain map with little flat ground.
  • A goblin junk town. peoples: ["goblin", "ratmen"], industry wanted early (demand.baseIndustrialJobs), and a script that sets an unruly block alight whenever crime (sos.city.stats(seat).problems.crime) runs high.
  • A farming valley. Homes that need food from level 1 (needs), long lean seasons (seasons), a soil layer painted from the map's rivers, and a script that drains a marsh to farmland with sos.terrain.set once a city reaches a village.

What a map cannot change yet

The city's kinds of things are the game's own: its zones, services, peoples, goods and building looks, and its four settings. A map can retune all of them, script around them and draw on top of them, but it cannot add a new service or a new good. Cliff heights and pathing blockers stay as authored. The full list of what maps can and cannot do is the Capability map.