Files

18 KiB

Writing scenarios

Guide for anyone (human or model) adding a new fictional country or situation to "The Desk". A scenario is one JSON file that sets the start state; the rules, the card pool, the newspaper and the endings are shared. Read docs/BALANCE.md and docs/CARD-WRITING.md first. Reference scenario: web/data/scenarios/default.json (Valdoria).

1. What a scenario can change

createGame (web/js/engine/game.js) reads these fields, and nothing else:

Field What it does Where it shows
id key of the scenario, saved in every save file name, index.json, saves
name {en, fr} default country name (the player may type another) menu list, save slots, newspaper {country}, export file name
currency text only "bn VLD" on the dashboard and the ledger
startDate YYYY-MM-DD, day 0 calendar, card dates, end screen
economy gdp, debtRatio, growth, unemployment, inflation, otherRevenue, the three start tax rates everything fiscal
indicators the 9 start values (0 to 100) dashboard, and the equilibrium (section 3)
sectors need and alloc (% of GDP) for the 15 sectors ledger, quality, cost of every lever

What a scenario cannot change without engine work. All of it lives in shared files:

  • Tax structure: the three taxes, their yield coef, Laffer peak, opinion and growth effects (balance.json tax). A scenario only picks start rates and otherRevenue.
  • The 15 sectors (all must be present, and balance.json sectorTau / needGrowth must match), the 9 indicators, the 3 economy variables.
  • How things react: indicator weights, drift, gain, world cycle, game-over thresholds, score formula, needGrowth.
  • Characters (characters.json: fixed names and roles), the newspaper (newspaper.json, called "The Daily Herald"), endings and historian titles (endgame.json), the length of the term (1826 days).
  • Card text. About 140 mentions of "Valdoria / Valdorian" (both languages) sit in card text, written literally; card bodies are not run through {country} (only the newspaper intro is). A new country therefore plays with Valdorian cards. Write the premise so this is tolerable (a sister state, a Valdorian region or era), or ask for {country} substitution in cards as engine work. Scenario-specific cards (section 5) may name the country directly.
  • A scenario description or premise: the menu only reads name.

What really makes a scenario feel different, given shared rules:

  1. Where the start sits. Indicators move toward start value + weighted deviations, so a country starting at opinion 35 stays near 35 until the player acts.
  2. Need versus alloc. Quality is 100 * (1 - exp(-0.9 * alloc/need)); the price of one more point of ratio is need % of GDP, so a sector with a small need is a cheap lever and a big need is an expensive one.
  3. The fiscal squeeze: debt interest and taxes already close to their Laffer peak leave little room to move.
  4. Scenario cards that stage the central tension.

2. Design brief (fill in before writing numbers)

Write this in the scenario's pull request or notes; an agent that cannot fill every line is not ready to write JSON.

Question Example answer
Identity: country, era, one-sentence premise "A sugar island that borrowed to survive a hurricane."
Central tension: the two things the player cannot have together "pay the interest or keep the clinics open"
Temptation: the easy move that backfires "print growth with debt, or sell the ports"
First-year crisis the player will feel by week 8 "bond auction in month 3"
Difficulty profile tighter / same as default / easier, and which of the standard player types should change band (section 6)
Levers that are cheap and expensive (needs) health need 9 (expensive), police need 0.8 (cheap)
Which indicators start high, which low, which near a danger zone "unrest 44, but opinion 55: no zone"
Cards: which situations need a scenario-only card 15 to 40 cards, 3 to 5 chains

Use fiction only: no real country, person, party or brand (same rule as the cards). Prefer neighbours from the existing pool (Torvania, Marenia, Belmora, Kessaly, Draveth).

3. The scenario file

{
  "id": "saltmere",
  "name": { "en": "Free State of Saltmere", "fr": "État libre de Saltmère" },
  "startDate": "2028-01-03",
  "currency": "SLM",
  "economy": { "gdp": 22, "debtRatio": 100, "growth": 1.5, "unemployment": 9, "inflation": 3,
               "otherRevenue": 7, "taxes": { "income": 38, "corporate": 28, "vat": 26 } },
  "indicators": { "order": 60, "stability": 50, "civilRights": 66, "opinion": 48, "economy": 45,
                  "hardPower": 20, "softPower": 40, "unrest": 28, "armyLoyalty": 62 },
  "sectors": { "education": { "need": 4.5, "alloc": 4.0 }, "...": "all 15 sector ids, see default.json" }
}
Field Unit Sane range Notes
id lowercase letters and digits must equal the file name; avoid _ (it is the card id prefix); never change it after release
startDate ISO date any pick a Monday: cards arrive every 7th day from day 0 and the calendar shows weekdays. Never change after release (saves refuse a different date)
gdp bn of currency 15 to 2000 display and absolute debt only; must be above 0
debtRatio % of GDP 15 to 105 see the interest table below; the engine warns at 135 and defaults at 165
growth % per year -1 to 5 clamped to -15..12 by the engine
unemployment / inflation % 2 to 15 / 0 to 12 clamped 1..40 / -2..40
otherRevenue % of GDP 3 to 24 royalties, fees, aid; the main "resource state" knob
taxes.income / corporate / vat rate in % 15 to 45 / 10 to 40 / 10 to 30 stay under about 75 % of the peaks 60 / 50 / 40, or the player cannot raise them
indicators.* 0 to 100 15 to 85 all 9 keys required; keep unrest 10 to 45, armyLoyalty 40 to 85
sectors.*.need % of GDP 0.3 to 14 greater than 0; the 15 ids as in default.json
sectors.*.alloc % of GDP 0.5 to 1.5 times need engine accepts 0 to 3 times need

The equilibrium rule

createGame stores the start in state.ref (taxes, indicators, economy variables, sector quality, budget ratios, and the fiscal deviations). Every target is ref + gain * weight * (change since the start), so the start is an exact equilibrium whatever numbers you choose: the start deficit is not punished, and a high start inflation does not hurt by itself. A hands-off year still slides (default: order -5, stability -6, opinion -5, economy -6, unrest +8, army loyalty -5) because of the yearly drift and growing needs; that slide is the intended pressure.

Some rules read absolute levels, so the start must respect them:

  • Interest and default. Rate = 3 + 0.0008 * (debtRatio - 40)^2 %, interest = ratio * rate. Default at 165, warning at 135, and the budget must pay the interest every year:

    debt / GDP 40 58 70 80 90 100 110 120 135
    interest, % of GDP 1.2 1.9 2.6 3.4 4.5 5.9 7.6 9.7 13.8

    Industry quality also loses 0.25 per point of debt above 90.

  • Game-over zones: revolution (unrest above 62 with opinion below 28), coup (army loyalty under 35 with stability under 50, or loyalty under 18), impeachment (civil rights under 10 with opinion under 25, 60 days). Start at least 15 points away from each.

  • Quality shaping. Excess terms start at sector quality 70 (ratio 1.34) and short terms at 40 (ratio 0.57): outside that band the target is offset from the start. Keep ratios in 0.6 to 1.3 for health, social, housing, industry, infrastructure, education, research, police, justice, culture, foreign affairs.

  • Score: debt (ref 60), average growth (ref 2) and unemployment (ref 6.5) are absolute, so a high-debt or slow start scores lower (debt 100 costs 8 points). This is why a scenario may need its own bands (section 6).

  • Cards gated on debt (for example bond panic at 100, rating warning at 62, lenders at 105) fire from day 0 if the start is above them.

The start fiscal picture

All flows are % of GDP per year (economy.js):

  • Tax revenue = coef * rate * (1 - rate / (2 * peak)), with coef 0.5 / 0.25 / 0.55 and peak 60 / 50 / 40 for income / corporate / VAT. Revenue peaks at the peak rate (15 / 6.25 / 11 % of GDP); at the default rates (half the peak) one extra point yields 0.25 / 0.125 / 0.275.
  • Raising a rate by d points costs opinion (0.5 / 0.15 / 0.6 times d, then times -1.2 on the opinion target) and growth (0.03 / 0.06 / 0.025 times d).
  • revenue = otherRevenue + sum of the three taxes; spending = sum of sector alloc + interest; deficit = spending - revenue.
  • Default: revenue 35.69, sectors 34.30, interest 1.89, deficit 0.50.
  • Aim for a start deficit between -1 and +1.5. The debt ratio holds when deficit is about growth * debtRatio / 100 (1.2 for the default), so a start above 2 spirals. Fix a gap by moving otherRevenue, tax rates, or sector alloc, never by changing a need (needs set prices, not budgets).
  • Order of work: pick debt and taxes, compute revenue and interest, then split the remaining budget over the 15 sectors.

4. Registering the scenario

  1. Save web/data/scenarios/<id>.json.
  2. Add the id to web/data/scenarios/index.json ("scenarios": ["default", "<id>"]). Keep default first: it is the pre-selected scenario, the name field's placeholder, and the fallback.
  3. Both name.en and name.fr are required (TestScenarioIndex). The menu shows the name only: the New game screen has a scenario <select> once there are two or more, and a blank country name field falls back to the chosen scenario's name in the current language. Names must be 40 characters or fewer (the field is maxlength 40).
  4. web/embed.go embeds the data folder, so no code change is needed; rebuild the Docker image (docker compose up --build) to ship it.
  5. Do not edit balance.json, the engine or other scenarios to make a scenario work. Changing numbers of a released scenario is safe (saves keep their own copy); changing id, startDate or the sector list breaks saves.

5. Scenario-specific cards (new feature)

Most of the pool is shared. A scenario can add cards visible only in that scenario through an optional card field:

{ "id": "saltmere_eco_hurricane_bonds_01", "category": "economy", "scenarios": ["saltmere"], "...": "same schema as CARD-WRITING.md" }
  • "scenarios": ["id", ...] is a whitelist. A card without the field is available in every scenario. There is no blacklist, so shared cards stay shared.
  • go test ./server validates it: a non-empty array of strings, each listed in scenarios/index.json.
  • node tools/add-cards.mjs <category> <batch.json> keeps the field when it merges a batch.
  • Id prefix <scenarioId>_, then the usual <cat>_<slug>_<NN>, for example saltmere_eco_hurricane_bonds_01. Ids stay unique across all files. Same category files and same schema, effects, size guide and voice as docs/CARD-WRITING.md.
  • Keep chains closed: a card and its follow-ups carry the same whitelist, and a shared card never follows up to a scenario card. Flags read by a scenario card must be set by a card available in that scenario.
  • Use conditions on the situation (min: {"debt": 90}, ratio:army, dayMin) so the cards appear where the story says. The scenario's tension cards may weigh 1.5.
  • Naming the country and its places is fine here (shared cards cannot do it). Both languages, no pronouns for people.
  • Budget 15 to 40 cards: about 5 available from the start, the rest spread over the term, and 3 to 5 chains. Run node tools/card-stats.mjs on the files you touched.

6. Validation

Command Checks
go test ./server scenario index (id equals the file name, localized name, listed and present), card schema including scenarios, i18n. The economy, sectors, indicators and taxes of a scenario other than default are not covered by Go tests: use the tools below.
node tools/engine-check.mjs engine rules (uses default.json)
node tools/scenario-check.mjs <id> prints the start fiscal picture (revenue, sector spending, interest, deficit, debt) and a one-year hands-off drift; warns when the start is far from balanced. The draft warns for a deficit above 2, debt above 110, sector ratios outside 0.5 to 1.5, indicators outside 5 to 95, and exits with 1 on any warning.
node tools/balance-check.mjs 24 --check --scenario <id> plays the scenario with the standard player types and compares them to the bands in balance-check.mjs (about 30 s)

Compare the hands-off drift with the default (about -5 on opinion, stability and economy, +8 unrest in a year). A scenario that collapses or ignores a year of inaction was built on the wrong numbers.

Own bands. A scenario that is deliberately harder or easier ships web/data/scenarios/<id>.targets.json, same shape as TARGETS and merged over the defaults:

{ "notes": "Debt 100 costs 8 score points and interest eats 6 % of GDP, so scores sit about 8 lower and spendMild is fatal.",
  "targets": { "plannerHuman": { "survive": [85, 100], "score": [48, 60], "top": [0, 40] }, "spendMild": { "survive": [0, 60] } } }

notes must say why the bands differ. Move only the bands that the premise explains; the extremes (ruin, starve, tyrant) must still end in a fall, and plannerHuman must still survive. Do not retune balance.json to rescue a scenario.

7. Scenario ideas

Ranges are starting points. The fiscal columns were checked by hand against the formulas (a midpoint gives a deficit near +0.5); run scenario-check on the real values.

Idea Premise Start ranges What makes it different
Debt-ridden island (saltmere) A tourism and sugar island borrowed to rebuild after a hurricane; lenders now watch every ledger. debt 95-105, gdp 15-40, growth 1-2, otherRevenue 6-9, taxes near 65 % of peak (38 / 28 / 26), sectors total 28-30, interest 5.5-6.5 Interest takes 6 % of GDP, so every service is squeezed and tax hikes hit the Laffer wall early; the pull is to borrow once more. Bands: lower scores, survival of spendMild near 0.
Resource-rich, weak army (karhaven) A gas exporter whose royalties fund the state while the army is a parade force next to hostile neighbours. otherRevenue 18-24, income 12-18, corporate 15-22, vat 10-14, debt 25-40, growth 3-5, sectors total 33-38; army need 3-4 with alloc 1.2-1.6, armyLoyalty 40-50, hardPower 20-30 Cheap taxes and a large windfall make spending easy; the danger is the army and a royalty crash (scenario cards with revenue: other cuts). Coup risk is the real threat.
Post-war republic (ashford) A republic after a short civil war, with an army that is loyal but restless about pay and demobilisation. debt 70-90, growth 3-4, unemployment 10-13, armyLoyalty 70-80, stability 40-48, order 40-50, unrest 33-42, taxes 24 / 20 / 18, otherRevenue 7-9, sectors total 26-29; army need 4 alloc 3.5-4; housing and justice need high, alloc 60-70 % Loyalty is high but erodes 1.1 points a year (drift), cutting the army is the shortcut to a coup, and reconstruction wants housing and justice first. Check revolution distance (unrest below 47).
Rich small state under a big neighbour (lysmark) A prosperous harbour state whose neighbour decides its trade and defence; it has to look independent without a real army. debt 15-30, growth 1-2, unemployment 3-4.5, taxes 22 / 15 / 14, otherRevenue 9-12, sectors total 26-30, deficit -1 to +0.5; softPower 60-70, hardPower 10-20, opinion 65-72, economy 65-72 Everything starts high so the drift hurts; foreign affairs and culture are the real levers, defence is nearly impossible. Scenario cards carry the neighbour's pressure. Bands: top grades rarer.
Ageing welfare state (aldermoor) A cold, orderly republic whose pensions and clinics grow faster than its economy. debt 70-85, growth 0.5-1.5, unemployment 4-5.5, social need 13-15, health need 8-10, taxes 36 / 24 / 24, otherRevenue 8-10, sectors total 32-35 Social and health needs are expensive levers and grow every year (needGrowth); growth sectors are starved. Tension is pensions versus investment; a slow growth start lowers the score floor (note it).

8. Checklist for the agent

  • Design brief filled in (section 2), fiction only, no real names.
  • web/data/scenarios/<id>.json has id, name.en, name.fr, startDate (a Monday), currency, economy (with the 3 taxes), 9 indicators, 15 sectors.
  • Start deficit between -1 and +1.5; debt within the interest table; unrest, army loyalty and opinion 15 points away from the danger zones; sector ratios 0.6 to 1.3 unless the premise says otherwise.
  • Added to index.json, default still first; names fit 40 characters.
  • Scenario cards (if any): scenarios whitelist, <id>_ prefix, closed chains, both languages, merged with add-cards.mjs.
  • node tools/scenario-check.mjs <id> clean, and the one-year drift looks like the default's.
  • node tools/balance-check.mjs 24 --check --scenario <id> passes, or a <id>.targets.json with a notes explaining each moved band.
  • go test ./server and node tools/engine-check.mjs pass.
  • Nothing outside web/data/scenarios/ and your own card files was edited (no balance.json, no code, no tests).
  • Played the first quarter in the browser: menu entry, first review, a week of cards, no immediate warning telegram.

Assumptions

  • The scenarios card field, scenario-check.mjs and --scenario exist and were exercised on the default scenario only; a second scenario has not been played yet.
  • The targets file is read as { "notes": "...", "targets": { player: {...} } } (what balance-check.mjs reads today), and a player entry replaces the default entry whole (shallow merge), so restate every band you keep.
  • Go tests currently load only default.json for economy, sector and card-condition checks.
  • Shared card text names Valdoria; the guide treats this as a known limit, not something a scenario can fix.
  • Suggested ranges come from reading balance.json and the formulas, not from playtesting the ideas.