Scene Syntax & Conditions

Writer reference for authoring scenes (Twine-like) in this project: structure, text blocks, choices, auto navigation, and the full condition language. This document describes what src/classes/game/util/sceneManager.js currently supports.

Contents

Where things live Scene definition Text blocks Choices Auto navigation Conditions Hour gates Numeric gates Localization vars Gotchas

Where things live

What Where
Scene runtime (resolver + renderer) src/classes/game/util/sceneManager.js
Scene packs (data) src/data/scenes/* (examples: src/data/scenes/sample/general.js, src/data/scenes/system/traversal.js)
Scene registry src/data/scenes/index.js (exports SCENE_DEFS)
Localization dictionaries src/data/i18n/en/*.js and src/data/i18n/pl/*.js
Common scene authoring helpers src/data/scenes/util/common.js (ex: TIME_OF_DAY, BREAK)
Mental model: the SceneManager continually selects the “best” scene whose conditions match the current game state (highest priority wins), then renders its text + choices.

Scene definition

A scene is a plain object. The only required field is id. Anything not listed in the tables below is ignored by SceneManager.

// src/data/scenes/yourPack/whatever.js
import { TIME_OF_DAY, BREAK } from "../util/common.js";

export const SCENES = [
  {
    id: "home.default",              // required, globally unique
    priority: 10,                     // higher wins if multiple scenes match

    // You can write `when`, `if`, `conditions`, or `condition` at scene root.
    when: {
      placeKeys: ["player_home"],
      notPlayerFlags: ["injured"],
    },

    // Opt-in auto navigation (default is OFF):
    // - traversal: inject travel choices when outside
    // - exit: inject an Exit choice when inside a place
    autoChoices: { exit: true },

    // Text can be a string key OR an array of blocks.
    text: [
      "scene.home.default.text",
      { when: { hour: TIME_OF_DAY.evening }, key: "scene.home.default.evening" },
      BREAK,
      { when: { playerFlags: ["injured"] }, keys: ["scene.injured.0", "scene.injured.1"] },
      { keys: ["scene.flavour.0", "scene.flavour.1"], pick: "random" },
      { raw: "(You can also include raw literal text.)" },
    ],

    choices: [
      {
        id: "home.tidyUp",
        text: "choice.home.tidyUp",
        minutes: 10,
        setFlag: "homeIsTidy",
      },
    ],
  },
];

Scene fields (supported)

Field Meaning Notes
id required Unique scene id. Used for debugging and nextSceneId jumps.
priority optional Resolver tie-breaker (higher wins). Defaults to 0.
when / if / conditions / condition optional Conditions under which the scene can appear. Same condition language as text blocks & conditional choices.
text optional String key or array of blocks to build the final scene text. See Text blocks.
textJoiner optional String inserted between adjacent text blocks. Implementation note In the current implementation, blocks are effectively concatenated directly (no automatic newlines). To insert line breaks, include \n/\n\n in your localized strings (or in raw).
choices optional Array of choice objects (buttons). See Choices.
autoChoices optional Opt in to auto-injected travel/exit buttons. Supported forms:
  • autoChoices: true (both traversal + exit)
  • autoChoices: { traversal: true }
  • autoChoices: { exit: true }
See Auto navigation.

Text blocks

text can be a single string, or an array of blocks. SceneManager processes blocks top-to-bottom.

Supported block types

  • String: treated as an i18n key (looked up via localizer).
  • Conditional block object: { when/if/conditions/condition: {...}, key: "..." }
  • Multi-key block: { when/if/...?: {...}, keys: ["...","..."] }
  • Random pick: { keys: ["...","..."], pick: "random" } (picks one key; stable while the scene is active)
  • Literal block: { raw: "Some unlocalized text" }
  • BREAK token: BREAK imported from src/data/scenes/util/common.js
    Note: in the current SceneManager implementation, BREAK does not insert any characters. Treat it as a structural marker (reserved for future). If you want visible spacing, add \n/\n\n inside your i18n strings (or use raw containing newlines).

Example

import { TIME_OF_DAY, BREAK } from "../util/common.js";

text: [
  "scene.home.default.text",
  { when: { hour: TIME_OF_DAY.morning }, key: "scene.home.default.morning" },
  BREAK,
  { when: { playerFlags: ["injured"] }, keys: [
      "scene.injured.0",
      "scene.injured.1",
  ]},
  { keys: ["scene.flavour.0", "scene.flavour.1"], pick: "random" },
  { raw: "(Raw text here.)" },
]
Anything written as a string is fed through the localizer. If the key is missing, the localizer returns the key itself. That means you can put literal text as a string, but it won’t be translated.

Choices

Choices are buttons shown under the scene. A choice can: advance time, move the player (location/place), set/clear flags, enqueue an urgent scene, and/or hard-jump to a specific next scene.

choices: [
  {
    id: "travel.toMarket",            // required (unique within the scene)
    text: "choice.travel.toLocation", // recommended (i18n key). If omitted, label falls back to id.

    minutes: 7,                        // optional (defaults to 0)
    hideMinutes: false,                // optional (default false). When true, hides the "(N minutes)" suffix.

    // Optional condition gate for showing the choice:
    when: { notPlayerFlags: ["injured"] },

    // If conditions fail, the choice is normally hidden.
    // showAnyway shows it as disabled (unclickable):
    // showAnyway: true,

    // Optional extra interpolation vars for the label:
    vars: { destName: "Market Square" },

    // Actions (pick what you need):
    moveToLocationId: "market_district", // move to a different location
    // setPlaceId: "shop_01",            // enter a place (place.key becomes currentPlaceKey)
    // setPlaceId: null,                 // exit to outside
    // exitToOutside: true,              // explicit exit to outside
    // moveToHome: true,                 // go home (uses game.homeLocationId/homePlaceId)

    // Flags:
    // setFlag: "waitingForPackage",    // or setFlags: ["a","b"]
    // clearFlag: "waitingForPackage",  // or clearFlags: ["a","b"]

    // Scene control:
    // nextSceneId: "special.cutscene", // hard jump (even if it wouldn't match)
    // queueSceneId: "ambulance.arrives",
    // queuePriority: 999,
  }
]

Choice fields (supported)

Field Meaning Notes
id required Unique within a scene. Used to dispatch the click.
text optional Localization key for the button label. If omitted, label falls back to id. The localizer receives scene vars + {minutes} + anything from vars.
minutes optional How much time passes when clicking. Defaults to 0. Also used for the “(N minutes)” suffix.
hideMinutes optional Hide the “(N minutes)” suffix. Default is false.
vars optional Extra interpolation variables for the choice label. { vars: { destName: "Market" } } enables {destName} in text.
when / if / conditions / condition optional Condition gate to show/enable the choice. If the condition fails, the choice is hidden by default. Combine with showAnyway to show disabled.
showAnyway optional Show the choice even when its conditions fail. When conditions fail and showAnyway: true, SceneManager renders the choice as disabled and refuses to execute it.
setPlaceId optional Enter/leave a place within the current location. Set to a string Place.id to enter; set to null to exit to outside. Entering sets game.currentPlaceId and also sets game.currentPlaceKey to the Place.key for that id.
exitToOutside optional Exit any place and return to “outside”. Equivalent to setPlaceId: null. Outside = no placeId and no placeKey.
moveToLocationId optional Move to another location. Also clears current place state.
moveToHome optional Shortcut to home location + home place. Uses game.homeLocationId and game.homePlaceId.
setFlag / setFlags optional Set story flags. Accepts string or array.
clearFlag / clearFlags optional Clear story flags. Accepts string or array.
queueSceneId optional Enqueue an urgent scene. Queue is resolved before normal scene matching.
queuePriority optional Priority for queueSceneId. Defaults to 999.
nextSceneId optional Hard-jump to a specific scene. Overrides normal matching (SceneManager forces that scene id). If the id is unknown, SceneManager routes to its fallback scene.
setPlaceKey not supported Legacy place movement key. If present on a choice, SceneManager throws an error. Use setPlaceId, exitToOutside, or moveToHome.

Auto navigation (optional)

SceneManager can auto-inject navigation choices into the currently active scene, but only when that scene opts in.

Definitions

Traversal choices (when outside)

If the active scene enables autoChoices.traversal, and the player is outside, SceneManager adds choices for:

Injected choices include vars: { destName }. If your scene already defines a choice with the same id, SceneManager does not add a duplicate.

Exit choice (when inside a place)

If the active scene enables autoChoices.exit, and the player is inside a place, SceneManager injects an Exit choice unless you already defined one.

Opt-in fields recap: autoChoices (boolean or { traversal/exit }), or menu/isMenu (both), with explicit overrides via autoTraversal / autoExit.

Conditions

Conditions decide whether a scene/text/choice is active. Wherever a condition block is supported, you can use any of: when, if, conditions, or condition.

Default behavior: a single condition object is an implicit AND across all of its keys.

Boolean combinators (all supported keys)

// Explicit AND / OR / NOT
when: {
  and: [ { ... }, { ... } ],   // alias: all
  or:  [ { ... }, { ... } ],   // alias: any
  not: { ... },
}

// Arrays are treated as implicit AND:
when: [
  { outside: true },
  { hour: { between: [18, 23] } },
]

Condition keys (matchers)

Key Meaning Example
locationIds / locationId Match if current location id is one of these. { locationIds: ["downtown"] }
locationTags / locationTag Match if current location has any of these tags. { locationTags: ["parkland", "urban_core"] }
placeKeys / placeKey Match if game.currentPlaceKey is one of these. { placeKeys: ["player_home"] }
outside Match only when the player is outside. { outside: true }
inPlace / insidePlace Match only when the player is inside any place. { inPlace: true }
weatherKinds / notWeatherKinds Match against world weather kind. { weatherKinds: ["rain", "storm"] }
seasons / notSeasons Match against world season. { seasons: ["winter"] }
dayKinds / notDayKinds Match day kind (from calendar): "workday" or "day off". { dayKinds: ["workday"] }
daysOfWeek / notDaysOfWeek Match day-of-week keys: sun, mon, tue, wed, thu, fri, sat. { daysOfWeek: ["sat", "sun"] }
date / notDate Match an exact date spec. Each spec may include any of: { year?, month?, day? }. Omitted fields are wildcards. Accepts a single spec or an array of specs. { date: { month: 12, day: 31 } }
dateRange / notDateRange Match an inclusive date range: { from: {..}, to: {..} }. Bounds may be partial. Accepts a single range or an array. { dateRange: { from: { month: 12, day: 20 }, to: { month: 12, day: 31 } } }
holidays / notHolidays Match if today includes any holiday/special name. Matching is case-insensitive. { holidays: ["New Year's Day"] }
npcsPresent / npcPresent Require all listed NPC ids to be at the current location. { npcsPresent: ["taylor"] }
hour / hours / hourOfDay Hour gate (UTC, 0–23). Supports several forms (see below). { hour: { between: [22, 5] } }
playerFlags / playerFlag Require all flags to be set. A flag is considered set if either:
  • game.hasFlag(flag) is true, or
  • player.getSkill(flag) exists with type === "flag" and a truthy value.
{ playerFlags: ["injured"] }
notPlayerFlags / notPlayerFlag Require all flags to be unset. { notPlayerFlags: ["injured"] }
playerStats / stats Gate on player stats by name. The value uses player’s computed stat by default. Each stat entry is a numeric gate. { playerStats: { strength: { gte: 5 } } }
playerStat Single-stat shorthand: { name|stat: "...", base?: true, ...gate }. { playerStat: { name: "strength", gte: 5 } }
playerSkills / skills Gate on numeric skill values (often 0..1 meters). Missing skills do not satisfy any gate. { playerSkills: { athletics: { gte: 0.6 } } }

Hour gates (UTC)

The hour condition supports several forms. Between ranges are start inclusive and end exclusive. If between: [x, x] the implementation treats it as “all day”.

import { TIME_OF_DAY } from "../util/common.js";

// exact hour
when: { hour: 18 }

// any of these hours
when: { hour: [18, 19, 20] }

// comparator gate (ANDed)
when: { hour: { gte: 18, lt: 23 } }

// between gate (wraps midnight when start > end)
when: { hour: { between: [22, 5] } }

// explicit eq / ne
when: { hour: { ne: 3 } }

// convenience buckets
when: { hour: TIME_OF_DAY.morning }

Numeric gates (stats & skills)

Numeric gates are used by playerStats, playerStat, and playerSkills.

Form Meaning Example
5 Exact match. { playerStats: { strength: 5 } }
[3,4,5] Any match. { playerStats: { strength: [3,4,5] } }
{ gte/gt/lte/lt } Comparator(s), AND-ed. { playerStats: { strength: { gte: 5 } } }
{ between: [lo, hi] } Inclusive range. { playerStats: { luck: { between: [2, 6] } } }
{ eq/ne } Explicit equality/inequality. { playerSkills: { stealth: { ne: 0 } } }

Base stat vs computed stat

// Default: computed/current value
when: {
  playerStats: {
    strength: { gte: 6 },
  }
}

// Use base stat (if the player model supports it)
when: {
  playerStats: {
    strength: { gte: 6, base: true },
  }
}

// Same idea with playerStat shorthand
when: {
  playerStat: { name: "strength", base: true, gte: 6 }
}

Localization vars

When text/choices are translated, the localizer receives a vars object. You can interpolate values in strings using braces: "It is {time.hhmm}", "You are on {street.name}", etc.

Available vars (scene text)

vars = {
  time: {
    hour,       // 0..23 (UTC)
    minute,     // 0..59 (UTC)
    hhmm,       // formatted time, e.g. "18:45"
  },
  location: {
    id,         // current location id
    name,
    tags,       // unique tag list
  },
  place: {
    id,         // current place id (or null)
    key,        // current place key (or null)
    name,       // place.name if inside a real place; otherwise a derived street/virtual name
  },
  street: {
    name,       // derived from any neighbor edge streetName (fallback "Street")
  },
  player: {
    stats: { /* precomputed numeric stats */ }
  },
  npcsHere: [
    { id, name },
    ...
  ],
}

Choice label vars

Choice labels receive all scene vars, plus: minutes (the choice minutes) and anything from choice.vars.

Example i18n string

// i18n dictionary:
"scene.street.default.text": "You are outside on {street.name}. It's {time.hhmm}."

// might render:
You are outside on Maple Street. It's 18:45.

Gotchas

Condition blocks are shared everywhere

Scene selection, conditional text blocks, and conditional choices all use the same condition engine. If it works in one place, it works in all three.

showAnyway renders disabled choices (and clicks do nothing)

If a choice fails its conditions and showAnyway: true, the choice is shown but disabled. Clicking it does not execute actions.

setPlaceKey is not supported on choices

If you put setPlaceKey on a choice object, SceneManager throws. Use setPlaceId, exitToOutside, or moveToHome.

Newlines and paragraph spacing

SceneManager does not reliably insert automatic newlines between blocks. If you need spacing, embed \n or \n\n inside your localized strings.

When nothing matches

If no scene matches, SceneManager falls back to either a configured fallback scene (constructor fallbackSceneId) or the first registered scene, so the game never hard-crashes. If you see an unexpected fallback, your conditions are probably too strict.