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, Lafferpeak, opinion and growth effects (balance.jsontax). A scenario only picks start rates andotherRevenue. - The 15 sectors (all must be present, and
balance.jsonsectorTau/needGrowthmust 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
descriptionor premise: the menu only readsname.
What really makes a scenario feel different, given shared rules:
- 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. - Need versus alloc. Quality is
100 * (1 - exp(-0.9 * alloc/need)); the price of one more point of ratio isneed% of GDP, so a sector with a small need is a cheap lever and a big need is an expensive one. - The fiscal squeeze: debt interest and taxes already close to their Laffer peak leave little room to move.
- 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)), withcoef0.5 / 0.25 / 0.55 andpeak60 / 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.6times d, then times -1.2 on the opinion target) and growth (0.03 / 0.06 / 0.025times 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 movingotherRevenue, tax rates, or sectoralloc, never by changing aneed(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
- Save
web/data/scenarios/<id>.json. - Add the id to
web/data/scenarios/index.json("scenarios": ["default", "<id>"]). Keepdefaultfirst: it is the pre-selected scenario, the name field's placeholder, and the fallback. - Both
name.enandname.frare 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 ismaxlength 40). web/embed.goembeds thedatafolder, so no code change is needed; rebuild the Docker image (docker compose up --build) to ship it.- 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); changingid,startDateor 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 ./servervalidates it: a non-empty array of strings, each listed inscenarios/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 examplesaltmere_eco_hurricane_bonds_01. Ids stay unique across all files. Same category files and same schema, effects, size guide and voice asdocs/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.mjson 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>.jsonhasid,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,defaultstill first; names fit 40 characters. - Scenario cards (if any):
scenarioswhitelist,<id>_prefix, closed chains, both languages, merged withadd-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.jsonwith anotesexplaining each moved band.go test ./serverandnode tools/engine-check.mjspass.- Nothing outside
web/data/scenarios/and your own card files was edited (nobalance.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
scenarioscard field,scenario-check.mjsand--scenarioexist 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: {...} } }(whatbalance-check.mjsreads today), and a player entry replaces the default entry whole (shallow merge), so restate every band you keep. - Go tests currently load only
default.jsonfor 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.jsonand the formulas, not from playtesting the ideas.