Documentation
LoomWrite Engine
Documentation
This document explains how the game engine works and how to extend it: the config file, scenes, enemies, items, currencies, leveling, endings, and achievements, plus every supporting system (conditions, effects, templating, save data, audio). It's written so you can hand it (plus your own story idea) to an LLM and ask it to generate new content in the correct shape, or so you can edit the data files by hand yourself. The engine is genre-agnostic — it ships with a sci-fi story (Meridian Ascent) and a small fantasy reference pack (Goblin's Due, under games/fantasy-demo/) demonstrating the systems the sci-fi pack doesn't use (dice combat, multiple currencies, leveling).
How the project is put together
No build step, no external dependencies, no bundler. The repo is split into the reusable engine and one folder per game (a content pack):
engine/
js/ // engine.js, combat.js, ui.js, save.js, audio.js, debug.js, main.js, asciiArt.js
css/ // style.css, content-guide.css
documentation.html, documentation.md // this file
games/
meridian-ascent/
index.html
js/data/ // config.js, story.js, scenes/*.js, enemies.js, items.js, currencies.js, achievements.js
audio/
patch-notes.html
fantasy-demo/
index.html
js/data/ // same shape, different content
Each game's index.html is fully self-contained — open it (or serve its folder) and it runs. Its content-relative <script> tags (js/data/*) point into its own folder; its engine-relative tags (../../engine/js/*) point back at the shared engine. Everything lives under window.Game, split into namespaces:
| Namespace | File(s) | Responsibility |
|---|---|---|
Game.Config | js/data/config.js (per game) | Global settings — see Configuration below |
Game.Data | js/data/story.js, js/data/scenes/*.js, js/data/enemies.js, js/data/items.js, js/data/currencies.js, js/data/achievements.js (per game) | All content: scenes, enemies, items, currencies, achievements |
Game.Engine | engine/js/engine.js | Scene navigation, conditions/effects, templating, leveling, save-on-navigate |
Game.Combat | engine/js/combat.js | Turn resolution for combat scenes (flat and dice modes) |
Game.UI | engine/js/ui.js | Everything DOM — rendering scenes/choices/combat/achievements/inventory, the title screen, the star field |
Game.Save | engine/js/save.js | localStorage persistence for the playthrough and for unlocked achievements, prefixed per game |
Game.Audio | engine/js/audio.js | Procedural ambient music (swappable presets) plus pre-recorded tracks |
Game.AsciiArt | engine/js/asciiArt.js | The hand-authored 5×7 block-letter font used for the title banner |
Game.Main | engine/js/main.js | Boot sequence — New Game / Load Game / Restart / return-to-title |
You should essentially never need to touch anything under engine/ for ordinary content work — everything described below as "content" is just data living in a game's own js/data/ folder.
Starting a new game
To build a new game on this engine: copy an existing game folder (games/fantasy-demo/ is the smaller starting point) to games/your-game-name/, keep its index.html's ../../engine/... script paths pointing at the shared engine/ folder, and then replace its js/data/ contents — config.js, story.js + scenes/*.js, enemies.js, items.js, currencies.js, achievements.js — with your own. At minimum, set a unique Game.Config.saveKeyPrefix (see Save data below) so its localStorage data doesn't collide with any other game hosted on the same domain.
Configuration
js/data/config.js sets Game.Config, a single flat object of global settings:
Game.Config = {
title: "Meridian Ascent",
saveKeyPrefix: "meridian_ascent",
accentColor: "#6983ec",
background: { preset: "stars" },
baseStats: { hp: 20, maxHp: 20, attack: 5, defense: 2 },
combatMode: "flat",
inputPlaceholder: "Please enter your name...",
bannerParts: [
{ word: "Meridian" },
{ text: "" },
{ word: "Ascent" }
],
stockPlayerName: "Jar Jar Binks",
typewriterCharsPerSecond: 1000,
typewriterEnabled: false,
clearTextBetweenChoices: true,
iconSetTag: "",
analyticsTag: "",
sceneTracks: { start: "opening", credits: "credits" },
audioTracks: { opening: "audio/opening_track.mp3", credits: "audio/credits_track.mp3" },
ambience: { mode: "procedural", proceduralPreset: "sci-fi-drone" }
};
| Field | Meaning |
|---|---|
title | Shown in the browser tab and set as document.title. |
saveKeyPrefix | Prefixes every localStorage key this game uses (save, achievements, debug flag, music preference). Required to be unique per game if multiple games share a domain — localStorage is scoped per-origin, not per-path, so two games without distinct prefixes would silently overwrite each other's save data. See Save data below. |
accentColor | Any valid CSS color. Overrides the engine's default accent (links, HP bar fill, active/hover states, etc. — --accent in engine/css/style.css) via a <style> tag injected into <head> before the stylesheet finishes loading, so there's no flash of the default color. |
background | { preset: "stars" | "clouds" | "mountains" | "vaporwave" | "image", imageUrl } — which scene renders full-viewport behind the game. The first four are pure CSS presets defined in engine/css/style.css; "image" instead loops imageUrl (any path or URL) as a cover background. Defaults to { preset: "stars" } if omitted. |
baseStats | Starting player stats — an arbitrary key/value object, not limited to hp/maxHp/attack/defense. A fantasy pack might add mana or luck; any currency's statKey (see Currencies) gets auto-seeded on top of this if not already present. |
combatMode | "flat" (default) or "dice" — which combat formula the whole game uses. See Dice combat mode below. An individual enemy can override this per-fight via its own combatMode field. |
inputPlaceholder | Placeholder text in the name-entry field at the start of a New Game. |
bannerParts | Renders the big block-letter banner on the title screen via Game.AsciiArt.renderBanner. An array of either { word: "TEXT" } (rendered in the hand-authored 5×7 block font — letters A–Z only) or { text: "small line" } (a plain line of normal text in between words, e.g. for a subtitle). |
stockPlayerName | The fallback player name — used if the name field is submitted blank, or if a player deliberately types this exact name (case-insensitive) at creation, which unlocks the great_minds achievement. Also used to resolve {name} anywhere a real state isn't available yet (e.g. the achievements panel when viewed from the title screen). |
typewriterCharsPerSecond | Reveal speed for the typewriter print effect, in characters per second. Only takes effect while typewriterEnabled is true. |
typewriterEnabled | true brings back the character-by-character "typing" reveal for scene text; false (current default) shows text instantly. |
clearTextBetweenChoices | true (current default): each choice clears the previous scene's text and shows a fresh screen. false: falls back to the old scrolling-transcript log, where every scene you've visited stays on screen, separated by a dashed rule, and you scroll down through history. |
iconSetTag | The exact <script> or <link> tag your Font Awesome account gave you (a Kit script, or a hosted CSS stylesheet link), pasted in as a raw string. Loaded into <head> on every page load so a choice's icon field (see Choice object) has a font to draw from. Leave "" to skip loading anything — see the note under Choice object for what happens to an icon-bearing choice when this is unset. |
analyticsTag | The exact tracking snippet your analytics provider gave you, pasted in as a raw string — works with anything that hands you a <script> tag (Tinylytics, Plausible, Fathom, GA/gtag, etc.). Loaded into <head> on every page load, same as iconSetTag. Leave "" to skip loading anything. |
sceneTracks | Maps a scene id to a key in audioTracks (or the special names "opening"/"credits"/"ambient") — that track keeps playing when the player lands on that scene, instead of falling back to the ambient loop. See Audio below. |
audioTracks | File paths (relative to this game's own index.html) for every pre-recorded track referenced by sceneTracks or played via Game.Audio.playTrack. |
ambience | Controls the "ambient" track (ordinary gameplay/combat music). { mode: "procedural", proceduralPreset: "sci-fi-drone" | "fantasy-tavern" } for the synthesized loop, { mode: "file", fileUrl: "audio/ambient.mp3" } to loop a real file instead, or { mode: "off" } for silence. See Audio below. |
There's no version field here. Versioning is an engine-level concern, not a per-game one — Game.Engine.version (engine/js/engine.js) is shown on every game's boot screen beneath the Achievements button, linking to engine/patch-notes.html, automatically. Bump it for engine/UI changes only; a game's own story/content changes don't touch it and don't need their own version number.
Scenes
How scenes are loaded
Every scene lives in its own file under js/data/scenes/, and registers itself directly onto Game.Data.scenes when that file executes:
// js/data/scenes/120_mess_hall_entry.js
Game.Data.scenes["mess_hall_entry"] = { id: "mess_hall_entry", /* ... */ };
js/data/story.js doesn't contain scenes itself — it just lists which scene files to load, and document.writes a <script> tag for each one:
var sceneFiles = [
"000_start",
"110_room",
"111_shower",
// ...
"999_credits"
];
sceneFiles.forEach(function (name) {
document.write('<script src="js/data/scenes/' + name + '.js"><\/script>');
});
To add a new scene: create js/data/scenes/your_file_name.js and add its filename (no .js) to the sceneFiles array in story.js. Nothing else needs to change — index.html only ever references story.js.
Filenames follow XYZ_name.js, where XYZ is a 3-digit code that places the scene in the story — for the author's own organization only; the engine never reads the filename, only the id field inside the file (that's what goto references point at):
| Digit | Meaning |
|---|---|
X | Act — 0 for the opening/prologue, 1 for Act 1, 2 for Act 2, etc. |
Y | Scene number within that act |
Z | Event/beat within that scene — 0 is the scene's own entry point, 1+ are follow-on beats branching off it |
Endings and the credits scene (901, 999) live in a separate reserved 9xx block outside this scheme, since they aren't part of the linear Act/Scene sequence. To insert a new beat, pick an unused Z within the right scene, or a new Y for a whole new scene; only renumber everything if you run out of room.
A mismatch between a sceneFiles entry and the actual filename (including forgetting to drop a stray .js suffix, or renaming a file without updating this list) means that scene silently never loads — its id will resolve to nothing, and navigating to it just logs Unknown scene id to the console and leaves the previous screen up. Whenever you rename a scene file, update sceneFiles in the same edit.
Scene object
Every scene is a property of Game.Data.scenes, keyed by its own id:
scenes["scene_id_here"] = {
id: "scene_id_here", // must match the object's key
type: "narrative", // "narrative" | "combat" | "ending"
text: "The narrative text shown when this scene loads.",
onFirstVisit: [ /* effects, optional */ ],
onEnter: [ /* effects, optional */ ],
choices: [ /* choice objects, narrative/ending scenes only */ ]
};
textis usually a plain string, but can instead be an array of{ conditions, text }variants — see Text variants below. This applies to choicetexttoo, since both flow through the sameGame.Engine.interpolate.- Whichever string ends up selected (the plain string, or the matched variant's
text) supports\nfor paragraph breaks, the{name}token (see Player name below),{% if %}conditional blocks (see Conditional text below), and inline markup — see Inline text formatting below. onFirstVisiteffects run exactly once, the first time a player reaches that scene (guarded automatically — you don't need to write your own "have I been here before" flag for this).onEntereffects run every time a player reaches that scene, including the first time (so a scene with both runsonFirstVisitthenonEnteron visit 1). Use this for a visit counter (addFlag) that lets one scene show different text/choices on repeat visits instead of forking off a second_againscene — see Gating repeat visits without a duplicate scene below. Note:onEnterdoes not fire when resuming a save (reloading the page and clicking Load Game just re-displays whatever scene you were already on — that isn't a fresh visit).- Scene
ids must be unique across the whole file and must exactly match a JS-safe key (letters, numbers, underscores).
Narrative scene
Has a choices array (see Choice object below). This is the default scene type — most of your content will be this.
Combat scene
Replaces choices with a fixed set of fields:
scenes["some_fight"] = {
id: "some_fight",
type: "combat",
enemyId: "goblin", // must match a key in Game.Data.enemies
introText: "A goblin leaps out!",
allowFlee: true, // false disables the Flee option entirely
onWin: "scene_id_after_win",
onLose: "scene_id_after_loss", // typically a death/game-over scene
onFlee: "scene_id_after_flee" // omit if allowFlee is false
};
Combat itself (attack rolls, HP bars, turn order) is handled entirely by the engine — you never author individual combat turns. You just supply the enemy and the three exits. See Enemies & combat below for exactly how a turn resolves.
Ending scene
scenes["ending_something"] = {
id: "ending_something",
type: "ending",
text: "Closing narrative text.",
choices: [] // always empty — the UI adds a "Play Again" button instead
};
See Endings below for the full picture — how choices behaves here, and the pattern for chaining a real story ending into the credits scene.
Choice object
Used inside a narrative scene's choices array:
{
id: "choice_id_here", // unique within this scene
text: "What the player sees on the button.",
icon: "fa-thin fa-check", // optional — see below
conditions: [ /* optional, see below — omit or [] for an always-visible choice */ ],
effects: [ /* optional, see below — omit or [] for a choice with no side effects */ ],
goto: "target_scene_id" // required — the scene this choice leads to
}
Gated choices are hidden, not disabled. If a choice's conditions aren't met, the button simply doesn't appear — the player never sees text implying an option they don't actually have.
When a choice is selected: its (interpolated) text is echoed to the transcript, its effects run in order, then the engine navigates to goto.
icon (optional): a class string for an icon font, e.g. "fa-thin fa-check" for Font Awesome. Unlike everything else in this guide, this isn't rendered from data alone — it needs an icon font actually loaded on the page first, via Game.Config.iconSetTag (see Configuration above). Internally it's rendered as <i class="choice-icon fa-thin fa-check"></i> right before the choice's label; the icon font's own CSS supplies the glyph via those classes, the same as it would on any other page using Font Awesome. If iconSetTag isn't set, an icon field doesn't break anything — the element is just empty, so the choice renders exactly as if icon had been omitted.
Conditions
Used in a choice's conditions array (all conditions in the array must pass — implicit AND) or nested inside or/not. Also reused, in exactly the same shape, by text variants and by individual effects.
| Type | Shape | Meaning |
|---|---|---|
hasItem | { type: "hasItem", item: "rusty_sword", qty: 1 } | Player has at least qty of that item id |
statAtLeast | { type: "statAtLeast", stat: "attack", value: 7 } | stat is a key in the player's stats block (see below) |
statAtMost | { type: "statAtMost", stat: "hp", value: 5 } | |
flagTrue | { type: "flagTrue", flag: "knowsCaveSecret" } | A story flag set by an earlier effect is truthy |
flagFalse | { type: "flagFalse", flag: "knowsCaveSecret" } | |
flagEquals | { type: "flagEquals", flag: "someFlag", value: "someValue" } | Flags can hold strings/numbers too, not just booleans |
flagAtLeast | { type: "flagAtLeast", flag: "trust_medic", value: 2 } | For numeric flags, e.g. a trust counter or a visit counter built with addFlag |
flagAtMost | { type: "flagAtMost", flag: "shower_visits", value: 1 } | |
or | { type: "or", conditions: [ ... ] } | Passes if any nested condition passes |
not | { type: "not", condition: { ... } } | Inverts a single nested condition |
The same conditions array can also be attached to an individual effect (in onFirstVisit, onEnter, or a choice's effects) to make that one effect conditional without gating the whole choice or scene — e.g. { type: "achievement", achievement: "squeaky_clean", conditions: [{ type: "flagEquals", flag: "shower_visits", value: 2 }] } only unlocks on the visit where the counter hits exactly 2.
Effects
Used in a choice's effects array, or a scene's onFirstVisit/onEnter array. Effects run in order and mutate the player's state.
| Type | Shape | Meaning |
|---|---|---|
addItem | { type: "addItem", item: "silver_key", qty: 1 } | Adds to inventory (stacks if already carried) |
removeItem | { type: "removeItem", item: "silver_key", qty: 1 } | Removes from inventory; entry disappears at 0 |
setFlag | { type: "setFlag", flag: "knowsCaveSecret", value: true } | Flags can be any JSON value, but boolean true is the common case |
addFlag | { type: "addFlag", flag: "trust_medic", amount: 1 } | Adds amount to a numeric flag (creating it at 0 first if unset); negative amounts subtract. Put this in onEnter to build a visit counter for a scene |
addStat | { type: "addStat", stat: "attack", amount: 2 } | Adds (or subtracts, with a negative amount) to a stat; hp is automatically clamped to [0, maxHp] |
setStat | { type: "setStat", stat: "defense", value: 2 } | Sets a stat to an exact value |
addXP | { type: "addXP", amount: 25 } | Adds to stats.xp and checks Game.Config.levelCurve for any newly-crossed threshold — see Leveling & XP below. A no-op if levelCurve isn't configured. |
equipItem | { type: "equipItem", item: "rusty_sword", slot: "weapon" } | Sets equipped.weapon / equipped.armor — cosmetic bookkeeping only; equipping something does not currently change combat stats (see Extending the engine below if you want that) |
fullHeal | { type: "fullHeal" } | Sets hp to maxHp — useful right before a big fight so players aren't punished for HP lost earlier on an unrelated path |
randomAssign (pick1) | { type: "randomAssign", mode: "pick1", flag: "quarters_badge_room", options: ["mess_hall", "bathroom", "cabin_1"] } | Picks one string at random from options and sets flag to it, exactly like setFlag with that value. Read it back later with flagEquals |
randomAssign (pickN) | { type: "randomAssign", mode: "pickN", count: 2, flags: ["pod_1_hostile", "pod_2_hostile", "pod_3_hostile"] } | Picks count distinct flag names at random from flags and sets each to true; the rest are left untouched. Read each one back later with flagTrue |
achievement | { type: "achievement", achievement: "squeaky_clean" } | Unlocks an achievement — see Achievements below for the full picture |
randomAssign is the one effect type that introduces true randomness — it should only ever appear in a scene's onFirstVisit array, never in onEnter or a choice's effects, so the roll happens exactly once and is locked in by the existing "runs once per scene" guard rather than re-rolling on every visit or every choice click.
Player stats block
stats: { hp, maxHp, attack, defense, /* + any currency statKeys, + xp/level if levelCurve is set */ }
Starting values come from Game.Config.baseStats (see Configuration above) — not hardcoded in the engine, so a game can declare whatever stat shape it needs. Meridian Ascent uses hp: 20, maxHp: 20, attack: 5, defense: 2; a game using combatMode: "dice" can reuse the same attack/defense keys, since they double as the to-hit bonus and AC-derivation there too (see Dice combat mode below).
On top of baseStats, createInitialState also seeds one stat per entry in Game.Data.currencies (at that currency's startingAmount, if not already present in baseStats), and, only if Game.Config.levelCurve is set, xp: 0 and level: 1.
Any stat key can be referenced in statAtLeast/statAtMost conditions or modified with addStat/setStat effects — currencies and XP are ordinary stats and use the exact same vocabulary; see Currencies and Leveling & XP below.
Text variants
This is the preferred way to give a scene (or a choice) different wording depending on game state — especially when there are more than two cases, like a room whose description changes across 4-5 visits. Instead of a plain string, make text an array of { conditions, text } entries:
text: [
{ conditions: [{ type: "flagAtMost", flag: "location_visits", value: 1 }], text: "First-time description..." },
{ conditions: [{ type: "flagAtMost", flag: "location_visits", value: 3 }], text: "Second/third visit description..." },
{ conditions: [], text: "You've been here plenty. There's nothing new to see." }
]
Entries are checked top-to-bottom and the first one whose conditions pass wins — same conditions shape and evaluator used everywhere else in this guide (flagAtMost, flagAtLeast, flagEquals, etc.), so an ascending chain of flagAtMost thresholds behaves exactly like if / elseif / elseif / else. An entry with conditions: [] always matches, so put your fallback/default last. This scales cleanly to as many stages as you need — unlike the {% if %} template block below, which only supports a single if/else and gets unreadable fast if you try to nest it for more branches.
Conditional text
Within whichever string ends up selected (a plain text, or the matched variant's text above), you can also embed a {% if %} / {% else %} / {% end %} block for a small inline wording tweak that doesn't deserve a whole separate variant — e.g. one sentence changing based on a boolean flag:
The corridor lights flicker. {% if flag(fixed_lighting) %}At least they're steady now, thanks to you.{% else %}Still on your list to fix.{% end %}
The condition inside {% if %} is always flag(NAME) optionally followed by a comparison:
| Form | Meaning |
|---|---|
flag(NAME) | Truthy check, same as flagTrue |
flag(NAME) == "text" / != "text" | String/value equality (quotes optional for bare words) |
flag(NAME) >= 2 / <= 2 / > 2 / < 2 | Numeric comparison, for counters built with addFlag |
!flag(NAME) ... | Leading ! negates the whole expression |
{% else %} is optional (omit it for "show this text only if the condition is true, nothing otherwise"). There's no {% elseif %} and blocks can't nest — for anything beyond a single true/false split, use Text variants above instead. {% if %} blocks are independent of the {name} token substitution — both work in the same string, in any order.
Inline text formatting
Whatever text is finally selected/composed gets run through the same formatter for scene text, choice text, and choice echoes alike. It supports:
| Syntax | Renders as |
|---|---|
**bold** | <strong> |
*italic* | <em> |
"quoted text" | Automatically styled as spoken dialogue (a softer gray, distinct from narration) — no extra markup needed, just write dialogue in straight double quotes |
[link text](https://example.com) | A real hyperlink, opening in a new tab (target="_blank") so players are never navigated away from the game |
Any stray < or > you type is escaped automatically, so raw HTML tags won't render — those four are the only special syntaxes. (Internally, the escape pass runs first, then bold, then italic, then the dialogue-quote pass, then the link pass last — the link pass has to run last because it's the only one that introduces new literal " characters, in the generated href/rel attributes, which would otherwise get re-matched and mangled by the dialogue pass.)
Player name
The player enters a name at the start of a New Game (stored as playerName in their save). Any scene text or choice text can include the literal token {name} and it will be substituted at display time — e.g. "\"{name}...\" a voice calls from the dark." The fallback if a name is somehow blank is Game.Config.stockPlayerName.
If a player leaves the name field blank and mashes "Begin Adventure" 5 times, the game auto-starts them under the stock name and unlocks the impatient_player achievement. If a player instead deliberately types the stock name (case-insensitive), that unlocks a different achievement, great_minds.
Gating repeat visits without a duplicate scene
Older content in this game used to use a second scene (e.g. shower → shower_again) purely to reflect that the player had already been somewhere. Prefer gating one scene with a visit-counter flag instead — it keeps the location's text, choices, and one-time achievement together in a single file. The real pattern, from js/data/scenes/111_shower.js:
Game.Data.scenes["shower"] = {
id: "shower",
type: "narrative",
onEnter: [
{ type: "addFlag", flag: "shower_visits", amount: 1 },
{
type: "achievement",
achievement: "squeaky_clean",
conditions: [{ type: "flagEquals", flag: "shower_visits", value: 2 }]
}
],
text: [
{
conditions: [{ type: "flagAtMost", flag: "shower_visits", value: 1 }],
text: "The water is rationed to one shower every other day... **Where would you like to go?**"
},
{
conditions: [],
text: "You're back in the shower. You've already used your daily shower ration. There's nothing to do here."
}
],
choices: [
{ id: "to_breakfast", text: "Head to breakfast", conditions: [], effects: [{ type: "setFlag", flag: "took_shower", value: true }], goto: "mess_hall_entry" },
{ id: "to_room", text: "Go back to your room", conditions: [], effects: [], goto: "room" }
]
};
onEnter increments the counter on every visit (including the first), the scene's own text branches on it via Text variants, and any choice that should only appear on the first (or Nth) visit gets a flagAtLeast/flagAtMost condition the same way. The achievement effect's own conditions makes it fire exactly once, on the visit where the counter first reaches the target value — unlockAchievement's built-in per-playthrough dedup means you technically don't need the conditions for correctness, but without it the effect is dead code (evaluated and silently no-op'd) on every other visit, so it's clearer to only run it when it can actually fire.
Worked example: adding a new side branch
Say you want to add an optional detour at river_bank where the player can fish, sometimes finding a ring.
scenes["river_bank"].choices.push({
id: "go_fishing",
text: "Try your luck fishing in the shallows",
conditions: [],
effects: [{ type: "setFlag", flag: "wentFishing", value: true }],
goto: "fishing_result"
});
scenes["fishing_result"] = {
id: "fishing_result",
type: "narrative",
text: "You wade in and cast a makeshift line. After a while, something catches — not a fish, but a tarnished ring snagged on an old hook.",
onFirstVisit: [{ type: "addItem", item: "tarnished_ring", qty: 1 }],
choices: [
{ id: "back_to_river", text: "Head back to the riverbank", conditions: [], effects: [], goto: "river_bank" }
]
};
That's the whole pattern: add a choice pointing at a new scene id, then define that scene (in its own file, registered in story.js's sceneFiles). No other file needs to change unless the new content should gate something later (in which case, add a conditions entry referencing wentFishing or tarnished_ring wherever you want it to matter).
Enemies & combat
Every enemy is a property of Game.Data.enemies (per game — Meridian Ascent's js/data/enemies.js is currently an empty stub, since Act 1 has no combat; games/fantasy-demo/js/data/enemies.js has a worked combatMode: "dice" example):
enemies["enemy_id_here"] = {
id: "enemy_id_here",
name: "Display Name",
hp: 18,
maxHp: 18,
attack: 5,
defense: 2,
loot: [ { item: "item_id", chance: 1.0 } ], // chance is 0–1; [] for no loot
fleeDifficulty: 0, // 0 = flee always succeeds, 1 = never
xp: 15, // optional — granted via addXP on win; omit/0 for none
ac: 12, // combatMode: "dice" only — see below
damageDice: "1d6", // combatMode: "dice" only
combatMode: "dice" // optional — overrides Game.Config.combatMode for just this enemy
};
An enemy only becomes reachable in-game by referencing its id from a type: "combat" scene's enemyId (see Combat scene above).
How a combat turn actually resolves
This is handled entirely by Game.Combat (engine/js/combat.js) — you never author individual turns, but it's worth knowing the exact math if you're balancing a new enemy. Every game picks one formula via Game.Config.combatMode (an enemy's own combatMode overrides it for that fight); flat is the default and is what's described here — see Dice combat mode below for "dice".
- Attack: damage =
max(1, player.attack - enemy.defense + variance), wherevarianceis a random-1,0, or+1. - Defend: deals half the normal damage (
round((attack - defense) / 2), minimum 1) and adds +2 to the player's effective defense for the enemy's next hit only. - Item: any inventory item with
usable: trueinGame.Data.itemsgets its own "Use {name}" button in combat — see Items below. There's no special-casing by item id; anything usable works the same way, in and out of combat. - Flee: succeeds with probability
1 - enemy.fleeDifficulty; on success the fight ends immediately and the player is sent toonFlee. On failure, the enemy still gets a free turn against the player. - Enemy's turn: damage =
max(1, enemy.attack - player.effectiveDefense + variance), run automatically after every player action except a successful flee. - The fight ends the moment either side's HP hits 0 — win sends the player to
onWin(rolling eachlootentry independently against itschance, then grantingenemy.xpif set), loss sends them toonLose.
Dice combat mode
Set Game.Config.combatMode: "dice" (game-wide) or an individual enemy's own combatMode: "dice" (per-fight) for a classic D&D-style resolution instead of the flat formula above. Nothing about the interaction changes for the player — it's still one click per action (Attack/Defend/Flee/Use item); the dice are rolled internally and the result is reported as combat-log text, e.g.:
You roll 14 (+3) vs AC 12 — Hit! Damage: 1d6+2 → 5.
- To-hit:
1d20 + bonusvs the target's AC. A natural 20 always hits regardless of AC. - Player's bonus is
stats.attack(the same stat the flat formula uses); player's AC is10 + stats.defense(+2while defending, mirroring the flat mode's defend bonus). - Enemy's bonus/AC are its own
attack/acfields. - Damage, on a hit, is rolled from dice notation like
"1d6+2"(NdM+K, modifier optional). The enemy's comes from itsdamageDicefield. The player's comes from their equipped weapon — theGame.Data.itemsentry forequipped.weapon, via that item's owndamageDicefield (see Items below) — falling back toGame.Config.unarmedDamageDice(default"1d4") if unarmed or the weapon has nodamageDice. - Defend in dice mode: same to-hit roll, but on a hit only half the rolled damage (minimum 1) is dealt, same trade-off as flat mode's defend.
- Loot, XP-on-win, and flee are unchanged from flat mode — only the attack/defend resolution differs.
Both modes coexist in the same engine — Meridian Ascent (flat, unset combatMode defaults to it) and games/fantasy-demo/ (dice) are both live examples.
Balancing a new enemy
- A trash/early fight: enemy
attackroughly equal to the player'sdefense,hplow enough to die in ~4-5 player turns. e.g.attack: 4, defense: 1, hp: 15. - A boss fight: give the player a
fullHealeffect on the scene just before it so earlier HP loss doesn't unfairly stack against them. Keep enemyhplow enough that a player who only ever clicks Attack still wins the large majority of the time — a "climactic" fight that's actually a coin-flip feels unfair, not exciting. A tuned reference point that wins ~88% of the time on naive all-Attack play (higher with smart Defend/item use):attack: 5, defense: 2, hp: 18. fleeDifficultyabove ~0.7 makes a fight feel inescapable — use that intentionally (e.g. a required boss), not by accident.
If you add a new enemy and aren't sure it's fair, ask an LLM to simulate ~1000 fights against it (attack-only and defend-when-low strategies) before shipping it.
Items
Every item — usable or purely narrative/equippable — is a property of Game.Data.items (per game, js/data/items.js). Declaring an item here is only required if it needs to be usable or equippable; a purely narrative/quest item (something a scene checks for with hasItem but never "uses") doesn't need an entry at all — addItem/removeItem work on any item id whether or not it's declared.
items["healing_herb"] = {
id: "healing_herb",
name: "Healing Herb", // optional — falls back to a title-cased version of the id
usable: true,
useEffects: [ // the SAME effect vocabulary as everywhere else — see Effects above
{ type: "addStat", stat: "hp", amount: 10 },
{ type: "removeItem", item: "healing_herb", qty: 1 }
],
useLogText: "You chew the healing herb and feel your wounds start to close. HP: {hp}/{maxHp}."
};
items["sword"] = {
id: "sword",
name: "Rusty Sword",
equipSlot: "weapon", // cosmetic bookkeeping via equipItem, UNLESS combatMode is "dice" — see below
damageDice: "1d6+1" // combatMode: "dice" only — this weapon's damage roll
};
| Field | Meaning |
|---|---|
usable | If true, this item gets a "Use {name}" button anywhere the player has it — the inventory panel (outside combat) and, if it's in inventory during a fight, combat itself. There's no special-casing by item id anywhere in engine code; any usable item works the same way. |
useEffects | Ordinary effects (see Effects above), run via Game.Engine.applyEffects when the player clicks Use. A consumable should include a removeItem effect on itself, same as healing_herb above — using an item doesn't remove it automatically. |
useLogText | Shown (interpolated through the same {token}/{% if %} system as scene text) after useEffects run — write it against the item's *resulting* state (e.g. HP: {hp}/{maxHp}) rather than a fixed number, since the actual amount healed can be less than the effect's amount if the player was already near full. |
equipSlot | "weapon" or "armor" — lets a choice's equipItem effect target this item. Purely cosmetic in combatMode: "flat"; in "dice" mode, the equipped weapon's damageDice is what the player actually rolls for damage. |
damageDice | combatMode: "dice" only — this weapon's damage roll, e.g. "1d6+1". Irrelevant in flat mode. |
Currencies
A game can declare any number of currencies — gold, credits, reputation tokens, whatever fits — as properties of Game.Data.currencies (per game, js/data/currencies.js). Each one is just a regular player stat under the hood; declaring it here is what gets it auto-seeded at game start and displayed in the status bar.
currencies["gold"] = {
statKey: "gold", // the key in state.stats this currency reads/writes
name: "Gold",
icon: "<svg>...</svg>", // optional inline SVG, same convention as achievement/status-bar icons
startingAmount: 0
};
Earning and spending currency uses the exact same vocabulary as any other stat — no dedicated "currency" effect/condition types exist, and there's no built-in shop UI. A "buy" choice is just an ordinary gated choice:
{
id: "buy_sword",
text: "Buy the sword (10 gold)",
conditions: [{ type: "statAtLeast", stat: "gold", value: 10 }],
effects: [
{ type: "addStat", stat: "gold", amount: -10 },
{ type: "addItem", item: "sword", qty: 1 }
],
goto: "shop"
}
Declaring more than one currency just means more entries in Game.Data.currencies, each with its own statKey — every declared currency gets its own independent badge in the status bar.
Leveling & XP
Opt-in per game via Game.Config.levelCurve — leave it unset (as Meridian Ascent does) for a game with no leveling at all, where attack/defense/maxHp stay at their starting values unless a scene/choice effect changes them directly.
Game.Config.levelCurve = [
{ level: 2, xpRequired: 20, statBoosts: { maxHp: 5, attack: 1 }, fullHeal: true },
{ level: 3, xpRequired: 50, statBoosts: { maxHp: 5, defense: 1 } }
];
xpRequiredis a cumulative total, not a per-level delta — write the curve in ascending order.- Grant XP from anywhere with the
addXPeffect (see Effects above) — a combat win, a scene'sonFirstVisit, a choice, anywhere effects run. A combat win also automatically grants the enemy's ownxpfield, if set. - Every
addXPchecks the curve for newly-crossed thresholds — a single large grant can cross more than one level in one call, each firing its own toast. statBoostsis applied through the sameaddStateffect logic as everywhere else (sohpclamping, etc. all behaves consistently);fullHeal: trueadditionally tops off HP on that threshold.- A level-up shows the same toast component as an achievement unlock, labeled "Level Up" instead of "Achievement", and (while
levelCurveis set) the status bar shows a persistentLv. Nbadge.
Endings
An ending is just a scene with type: "ending". Two things are different about it compared to a narrative scene:
choicesis always[]— an ending scene doesn't offer choices to pick from.- The UI automatically appends a "Play Again" button. Selecting it calls
Game.Main.returnToTitle()— this clears the save and takes the player back to the title screen (not straight into a new playthrough; that's what the mid-game Restart button does instead, viaGame.Main.restartGame()).
Chaining a real ending into the credits scene
Because an ending scene's choices is always empty, nothing can goto out of one — so if you want a story ending to lead into the credits screen (js/data/scenes/999_credits.js, id: "credits") rather than just ending flatly, the pattern used by the two current endings is: make the story scene type: "narrative" with a single closing choice, and let credits be the one that's actually type: "ending". From js/data/scenes/901_ending_not_worth_it.js:
Game.Data.scenes["ending_not_worth_it"] = {
id: "ending_not_worth_it",
type: "narrative",
onFirstVisit: [{ type: "achievement", achievement: "not_worth_it" }],
text: `"Leave it," you say.
Nobody argues...
- NOT WORTH IT -`,
choices: [
{ id: "continue", text: "Continue", conditions: [], effects: [], goto: "credits" }
]
};
The credits scene itself is a fairly ordinary ending scene, just with a longer static text block listing credits and using [link text](url) for attribution links:
Game.Data.scenes["credits"] = {
id: "credits",
type: "ending",
text: `**MERIDIAN ASCENT**
Thank you for playing.
Story & Design — ...
Icons by Font Awesome Pro ([fontawesome.com](https://www.fontawesome.com/)), used under a commercial license.`,
choices: []
};
One more thing tied to endings specifically: the title screen and the credits scene are the only two places that play a dedicated pre-recorded music track instead of the game's procedural ambient loop — see Audio below if you're adding a scene that should do the same.
Achievements
js/data/achievements.js is the single source of truth for every achievement in the game — scene effects reference an achievement by id rather than inlining its title/text, so the achievements panel can enumerate every achievement that exists (including locked ones) without scanning every scene file:
// Declared in story-appearance order — the locked half of the
// achievements panel relies on this order.
Game.Data.achievements = {
impatient_player: {
title: "Whatever, Fine",
text: "Don't want to pick a name? Then I'll pick one for you."
},
squeaky_clean: {
title: "Squeaky Clean",
text: "What, did you forget your towel?"
}
// ...
};
To add a new achievement: add an entry here (in roughly the order a player would first be able to earn it — that ordering is what the locked/unlocked panel uses), then unlock it from wherever it should fire with the achievement effect:
{ type: "achievement", achievement: "squeaky_clean" }
That effect can go in a choice's effects, or a scene's onFirstVisit/onEnter (optionally with its own conditions, as covered in Effects above, if it should only fire under a specific condition on an onEnter that runs every visit).
What happens when an achievement unlocks
Game.Engine.unlockAchievement(id, state):
- Checks
Game.Save.hasAchievement(id)— if already unlocked, it's a silent no-op. This is what makes it safe to attach anachievementeffect to something a player can trigger repeatedly (a joke choice, a scene they can revisit) — only the first time actually does anything. - Looks up the definition in
Game.Data.achievements; an unknown id just logs a console warning and does nothing (no crash). - Persists the unlock via
Game.Save.unlockAchievement(id)— this writes to its ownlocalStoragekey ({saveKeyPrefix}_achievements_v1), separate from the playthrough save ({saveKeyPrefix}_save_v1), specifically so achievements survive a Restart or New Game instead of being wiped along with the run that earned them. See Save data below forsaveKeyPrefix. - Pops an on-screen toast (
Game.UI.showAchievement) with the achievement's title and text, interpolated through the same{name}-token/{% if %}system as scene text, sotext: "Congratulations, {name}!"resolves correctly. The toast auto-dismisses after 7 seconds.
The achievements panel
Reachable both from the title screen and from the in-game status bar (the trophy icon). It shows two lists:
- Unlocked, in the order you actually earned them (chronological, from the
localStoragerecord). - Locked, in the order they're declared in
achievements.js(i.e. roughly story order) — with the title and description spoiler-masked: every non-whitespace character replaced with?, so you can see the shape/length of the name and flavor text without it being readable. This lets a completionist see "there's an achievement here you haven't found yet, and roughly how long its name/description is" without spoiling it.
Platinum
achievements.js includes one special entry, platinum (declared last), that works differently from every other achievement — it's a meta-achievement for having earned literally everything else, and it's never unlocked the normal way. Don't attach { type: "achievement", achievement: "platinum" } to any scene effect; nothing does, or should.
The problem a normal one-shot unlock would have here: achievement unlocks are permanent, one-way flags in localStorage. If Platinum were persisted like that, a player who earned it and then got a new achievement added in a later content update would keep showing Platinum forever, despite no longer actually having every trophy. So instead:
Game.Engine.isPlatinumEligible()computes eligibility live, every time it's asked — "is every id inGame.Data.achievements, exceptplatinumitself, present inGame.Save.getUnlockedAchievements()?" Nothing is cached. This is what the achievements panel calls to decide whether to show Platinum unlocked or locked, so it can never be stale — add a new achievement later, and anyone who doesn't also have that one correctly drops out of Platinum again, automatically, no migration needed.- The celebratory toast still only needs to fire once per completion, though, not on every subsequent panel view. That's tracked separately via
Game.Save.getPlatinumToastCount()/setPlatinumToastCount()— not "is Platinum unlocked", but "how many real achievements existed the last time the toast was shown."Game.Engine's privatemaybeToastPlatinum(state)compares that against the current total and only toasts (and updates the count) when they differ, which happens exactly once per distinct "fully complete" total — including a second time if the achievement list grows later and gets fully re-earned. - That check runs from two places: right after any real achievement unlocks (
Game.Engine.unlockAchievement, the normal moment completion could happen), and once at boot (Game.Engine.checkPlatinum(), called frommain.js) — the boot-time check catches a player who was already fully complete from an earlier session, without needing a fresh unlock event to notice.
If you ever add a second meta-achievement like this, follow the same shape: compute its truth live from current data, don't persist the unlocked/locked state itself, and only persist whatever's needed to dedupe the toast.
Extending the engine
The vast majority of new content — new areas, branches, items, fights, endings, achievements, currencies, leveling — needs zero code changes; everything above is just data, including usable items (via Game.Data.items's useEffects — see Items above) and combat balance/mode (Game.Config.combatMode plus per-enemy fields — see Dice combat mode above). Reach for an actual engine code change only for genuinely new mechanics this guide doesn't already cover a data-driven way to express — a new effect/condition type, a new combat mode, a new UI element.
Save data
Game.Save (engine/js/save.js) persists two independent things to localStorage, both keyed under Game.Config.saveKeyPrefix (e.g. meridian_ascent_save_v1) so multiple games sharing a domain don't collide — localStorage is scoped per-origin, not per-path:
- The playthrough (
{saveKeyPrefix}_save_v1) — the entire state object (currentSceneId,stats,inventory,flags,visited,combat,playerName), overwritten on every single scene render (Game.Engine.renderScenecallsGame.Save.save(state)at the end of every narrative/ending render, and combat has its own save calls after every turn). There's no manual "save game" step — it's continuous. - Unlocked achievements (
{saveKeyPrefix}_achievements_v1) — kept deliberately separate so achievements survive a Restart/New Game that wipes the playthrough key.
The save loader also falls back to the pre-prefix legacy key (textrpg_save_v1) once if the prefixed key is empty — this only matters for Meridian Ascent, whose players had saves under that key from before saveKeyPrefix existed; a brand-new game has nothing to fall back to and doesn't need to think about this.
Loading a save re-displays whatever scene currentSceneId points to, without treating it as a fresh visit — onFirstVisit is naturally skipped (it's guarded by visited, which is part of the saved state), and onEnter is explicitly skipped too via renderScene's { fireEnter: false } option, since resuming a save isn't a real navigation event and shouldn't inflate a visit counter.
Audio
Game.Audio (engine/js/audio.js) exposes one main entry point content/engine code cares about: Game.Audio.playTrack(name), where name is "ambient" (governed by Game.Config.ambience — see below) or a key into Game.Config.audioTracks.
Which track plays for which scene is controlled by Game.Config.sceneTracks (see Configuration above), checked on every scene render — any scene id not listed there falls back to "ambient". If you want another scene to keep a pre-recorded track playing (e.g. extending the intro before it hands off to ambient), add its id to that map.
The "ambient" track itself is controlled by Game.Config.ambience.mode:
"procedural"(Meridian Ascent's default) — a synthesized, dependency-free loop; pickambience.proceduralPresetfrom the presets declared inaudio.js'sAMBIENT_PRESETStable. Two ship today:"sci-fi-drone"(low open-interval tones, a constant hum, filtered hiss) and"fantasy-tavern"(warmer major-leaning tones, a softer drone, a low rumble instead of hiss) — used bygames/fantasy-demo/. Add a new entry to that table for a different mood; every tone parameter (scale, oscillator type, filter, drone, noise) is preset-driven, nothing about the scheduling logic needs to change."file"— loopsambience.fileUrlinstead, using the same playback machinery as the opening/credits tracks."off"— silence during ordinary gameplay; the opening/credits tracks (if configured) are unaffected either way.
Checklist before you ship new content
- Every
gotopoints at a sceneidthat actually exists - Every combat scene's
enemyIdexists inenemies.js, and (if this game usescombatMode: "dice") hasacanddamageDiceset - Every new scene file is added to
sceneFilesinstory.js, with the exact same filename - Scene and choice
ids are unique (scene ids globally, choice ids within their scene) - Gated choices have a non-gated fallback choice where it matters (don't strand a player with zero visible choices)
- New usable items are declared in
items.jswithusable: trueand auseEffectsarray (see Items above) — no engine code change needed - New currencies are declared in
currencies.jsbefore anything grants/spends them - If this game uses
levelCurve, its thresholds are in ascendingxpRequiredorder - New achievements are declared in
achievements.jsbefore anything references them by id -
Game.Config.saveKeyPrefixis set and unique if this game shares a domain with any other game
Prompt template for an LLM
If you're asking an LLM (including a fresh Claude session with no memory of this project) to write new content for you, a prompt like this works well:
I'm extending a text RPG built on a data-driven scene format described indocumentation.md(attached/pasted below). Here's my story idea: [describe it]. Tell me upfront whether it should usecombatMode: "flat"or"dice", and whether it needs currencies or leveling. Please write the new entries as JS ready to paste into a new file underjs/data/scenes/(andjs/data/enemies.js/items.js/currencies.js/achievements.jsif it introduces new enemies/items/currencies/achievements), following the exact object shapes in the guide. Make sure everygotoandenemyIdreference resolves, remind me to add the new scene file(s) tosceneFilesinstory.js, and give at least one meaningful gated choice.
Paste this file's contents (or just point the LLM at it) along with that prompt.