Skip to content

Maeve // Lost Signal โ€” Authoring Guide

Last updated: 30 June 2026

This document explains how to write narrative content for the messaging engine. The idea: an author only needs to provide well-formed JSON files โ€” no code changes required.

Recommended tool: the Story Editor (built-in Godot plugin) lets you create and edit scenes without opening any JSON file. JSON is the storage format โ€” this guide documents its structure for advanced use and as a complete reference.

Vocabulary: in this document, scene and node refer to the same thing seen from two different places โ€” a scene in the JSON file = a node in the Story Editor graph. Both terms are interchangeable.


Table of Contents

๐ŸŸข Beginner โ€” read first ๐ŸŸก Intermediate โ€” when you need more ๐Ÿ”ด Advanced โ€” can be skipped for simple stories


Minimal tutorial โ€” your first scene

Before diving into all the features, here is a complete, working scene:

{
  "scenes": [
    {
      "id": "intro",
      "contact_id": "maeve",
      "messages_in": [
        { "text": "Hello." },
        { "text": "Are you there?", "pause": "short" }
      ],
      "choices": [
        {
          "text": "Yes, I'm here.",
          "message": "Yes, I can hear you.",
          "next": "scene_next"
        },
        {
          "text": "Who are you?",
          "message": "Who are you?",
          "next": "scene_next"
        }
      ]
    }
  ]
}

What this scene contains: - id โ€” unique identifier for the scene - contact_id โ€” the character sending the messages - messages_in โ€” messages received by the player, in order - pause โ€” delay before the next bubble (short, medium, long) - choices โ€” reply buttons shown to the player - message โ€” what the player sends when they click the choice - next โ€” the scene played next

Everything else in this document extends this model.


1. Overview ๐ŸŸข

The game automatically loads: - story.json for contact configuration and the starting scene - all JSON files in the dialogues/ folder for scenes

A dialogue file always contains a root object with a scenes key.

2. story.json ๐ŸŸก

Minimal Structure

{
  "start_scene": "ch1_intro",
  "contacts": [
    { "id": "maeve", "name": "+33 6 23 11 47 05", "is_main": true,  "avatar": null, "status": "network_issue" },
    { "id": "alex",  "name": "Alex",              "is_main": false, "avatar": null, "status": "online" }
  ]
}

Allowed Fields

  • start_scene: ID of the first scene the engine plays when a new game starts. This is always the main contact's opening scene.
  • start_contact: ID of the contact whose conversation is shown on screen at launch. Defaults to the main contact. Set this to a secondary contact if you want the player to start in someone else's chat โ€” the main contact won't appear in the list at all until they write. Requires the main contact's first scene to use resume_after_flag (so it waits for a flag before starting, rather than trying to play while the player is looking at another conversation).
  • menu_music: Godot path to the audio file looped in the main menu (e.g. "res://assets/music/menu.ogg"). Absent or empty string = no music on the main menu. Editable from the Settings panel in the Story Editor ("Menu music" field with a โ€ฆ browse button).
  • contacts: array of contacts.
  • id: unique identifier for the contact.
  • name: text displayed in the top bar. Used as the fallback if no translation is defined for the active language.
  • names: dictionary of localized names โ€” see below.
  • is_main: true to designate the main contact โ€” the character the engine treats as the central interlocutor when no special configuration is set. Only one contact can have is_main: true; the Story Editor automatically unchecks all others when you select one.
  • avatar: path to the contact's avatar image, or null. See below.
  • status: online, away, offline, network_issue.
  • history: pre-existing messages shown at the start of a new game. See below.
  • pending_scene: ID of a scene whose choices will be presented to the player as soon as they open the conversation. See below.

Localized names (names)

The names field lets you display a different name for a contact depending on the active game language, with no code changes required. It is entirely optional: if absent, name is used for all languages.

The most common use case is a contact whose displayed name is a role description or placeholder that needs to be translated โ€” before the story reveals the character's real identity, or because the contact's name is actually a title:

{
  "id": "unknown",
  "name": "Unknown number",
  "names": {
    "fr": "Numรฉro inconnu",
    "en": "Unknown number",
    "de": "Unbekannte Nummer"
  }
}

A proper first name ("Maeve", "Alex") generally doesn't need to be in names โ€” it is the same across languages and name alone is sufficient.

How it works:

  • At startup, the engine reads the active language and looks for the matching key in names.
  • If a translation is found, it replaces name for the entire session.
  • If the active language has no key in names (untranslated language, or names absent), name is used as the fallback.
  • A confirmed language change resets the game. Initial names are then loaded in the new language; narrative renames are applied again when their effects are replayed.

The language code must match exactly the suffix used in your dialogue files. If you have act1.en.json, the code is "en". If you have act1.de.json, the code is "de". A typo in the code ("EN" instead of "en", "fr-FR" instead of "fr") will silently fall back to name โ€” no error is raised.

Name priority order:

Priority Source Condition
1 Narrative rename (rename effect) Triggered by a scene effect during the game
2 names[language_code] Key matching the active language
3 name Default / fallback

Note: names only controls the name shown in the top bar and contact list before any narrative rename occurs. If a scene renames the contact via a rename effect, the renamed name takes over for the rest of the session. For narrative renames that also need to be translated, the rename effect supports a localized dict as its value โ€” see Effects below.


Contact avatars

The avatar field accepts a Godot resource path to an image (PNG, JPG, JPEG, or WEBP), or null to disable the avatar.

{ "id": "maeve", "avatar": "res://assets/avatars/maeve.png" }

Behavior: - If an avatar is set and the file exists: the image is shown as the contact's profile picture in both the contact list and the top bar. - If no avatar is set (null) or the file is missing: the contact's name initial is shown on an accent-colored background.

Recommended convention: place avatar images in assets/avatars/. The Story Editor's Contacts panel includes a โ€ฆ button to browse and select the image directly from Godot's file picker.

Recommended format: square image, PNG or WEBP. The engine automatically clips the image to a circle โ€” a square image ensures centered cropping without loss.

Recommended size: 512 ร— 512 px, under 1 MB. Larger images work but unnecessarily increase project size.


How the game opens

There are two ways to set up the game's opening:


Option A โ€” Main contact visible from the start (default)

No setup needed. The game opens directly on the main contact's conversation. The player sees the main contact immediately, even before any message is exchanged. Secondary contacts can send messages in the background during the story using trigger_after_scene.

Use this when the player already knows who they're talking to, or when the main contact writes first.


Option B โ€” Secondary contacts first, main contact triggered later

The player starts in a secondary contact's conversation. The main contact doesn't appear in the list at all until a specific reply unlocks them. The player can have short conversations with one or more secondary contacts โ€” and one of those replies triggers the main contact's first message.

Use this when the main contact is a stranger. Seeing an empty conversation with an offline contact before they've written anything would feel odd โ€” it's better to have them appear naturally, as if they just reached out.

This requires three things working together:

What Where Why
start_contact story.json Sets which contact is shown on launch
history + pending_scene on each secondary contact in story.json Pre-existing messages and unanswered questions
resume_after_flag on the main contact's first scene Makes them wait for a specific reply before appearing

Pre-existing conversations (history and pending_scene)

These two fields give the impression that the player has already been using the messaging app before the story begins. At the start of a new game, affected contacts show an unread badge โ€” unless the last message in history comes from the player ("out": true), in which case there is nothing to read.

history

An array of pre-written messages โ€” both incoming and outgoing โ€” displayed in the conversation history from the very first moment.

{
  "id": "alex",
  "name": "Alex",
  "status": "online",
  "history": [
    { "text": "Did you see the news this morning?", "time": "09:14", "out": false },
    { "text": "No, haven't checked yet.",           "time": "09:15", "out": true },
    { "text": "Call me when you can.",              "time": "09:16", "out": false }
  ]
}

Each entry contains: - text: message content. Can be a plain string or a localized dictionary {"fr": "...", "en": "..."} โ€” see below. - time: timestamp displayed below the bubble. "HH:MM" โ†’ shown as-is. "YYYY-MM-DD HH:MM" โ†’ shown as "DD-MM-YYYY HH:MM" (FR locale) or "YYYY-MM-DD HH:MM" (other locales) if the date is before today. - out: true if the message comes from the player, false if it comes from the contact.

Localized text in history

If your game is translated into multiple languages, text can be a dictionary instead of a string:

"history": [
  { "text": {"fr": "T'as vu les infos ce matin ?", "en": "Did you see the news this morning?"}, "time": "09:14", "out": false },
  { "text": {"fr": "Bah non.",                     "en": "No, not yet."},                       "time": "09:15", "out": true }
]

The engine picks the value matching the active language. If the active language has no key in the dictionary, it falls back to "fr". The format works the same way as names for contacts.

pending_scene

ID of an existing scene whose choices are presented to the player as soon as they open the conversation โ€” as if a question had been left unanswered.

{
  "id": "alex",
  "name": "Alex",
  "status": "online",
  "history": [
    { "text": "You coming tonight?", "time": "18:42", "out": false }
  ],
  "pending_scene": "alex_party_choice"
}

The scene referenced by pending_scene must exist in the dialogue files and contain a choices field. When the player selects a choice, the scene resumes normally โ€” the narrative continuation (next, flags, effects) applies exactly as for any other scene.

Important: history is not a played scene. No flag, variable, or effect is triggered by history entries. If your story depends on a flag that should have been set during the pre-existing conversation, set that flag through another mechanism โ€” pending_scene, a scene triggered at startup, or an initial value in the debug overlay.

Both fields are ignored if a save file exists โ€” the game restores the saved state, not the initial state.

Full example: Option B โ€” secondary contacts before the main contact

story.json:

{
  "title": "My Story",
  "start_scene": "maeve_intro",
  "start_contact": "alex",
  "contacts": [
    { "id": "maeve", "name": "Maeve", "is_main": true, "status": "offline" },
    {
      "id": "alex", "name": "Alex", "status": "online",
      "history": [
        { "text": "Did you see the news?", "time": "09:14", "out": false },
        { "text": "Call me back.",         "time": "09:16", "out": false }
      ],
      "pending_scene": "alex_pending"
    }
  ]
}

dialogues/scenes.json:

{
  "scenes": [
    {
      "id": "alex_pending",
      "contact_id": "alex",
      "messages_in": [],
      "choices": [
        {
          "text": "On my way.",
          "message": "I'll be there in 20 minutes.",
          "flag": "maeve_can_write",
          "next": "alex_end"
        },
        {
          "text": "Not tonight.",
          "message": "Sorry, tonight's complicated.",
          "flag": "maeve_can_write",
          "next": "alex_end"
        }
      ]
    },
    {
      "id": "alex_end",
      "contact_id": "alex",
      "messages_in": [
        { "text": "Ok, talk later then." }
      ]
    },
    {
      "id": "maeve_intro",
      "contact_id": "maeve",
      "resume_after_flag": "maeve_can_write",
      "messages_in": [
        { "text": "Hello?" },
        { "text": "Is anyone receiving my messages?" }
      ],
      "choices": [ ... ]
    }
  ]
}

What happens at launch: - The game opens on Alex's conversation, with his messages already visible and reply choices ready - Maeve doesn't exist anywhere on screen yet - The player replies to Alex โ†’ flag maeve_can_write is set โ†’ Maeve's messages arrive (unread badge) โ†’ the player opens her conversation and the story begins

Multiple secondary contacts: add as many contacts as needed, each with their own history and pending_scene. The player can switch freely between them. The flag that unlocks the main contact can be set by any one of those replies โ€” whichever the author designates.

3. Dialogue File (dialogues/*.json) ๐ŸŸข

Each file contains:

{
  "scenes": [
    {
      "id": "scene_01",
      "contact_id": "maeve",
      "messages_in": [ ... ],
      "choices": [ ... ]
    }
  ]
}

Scene Fields

  • id: unique identifier. IDs are global โ€” every scene across all your files must have a different ID. Recommended convention: prefix with the file or act name to avoid collisions on larger projects (act1_intro, act2_confrontation). A short ID (intro) is fine for single-file projects.
  • contact_id: identifier of the contact speaking.
  • _notes: ignored by the engine โ€” use freely to annotate your scenes. E.g. "_notes": "Opening scene โ€” revision planned".
  • trigger_after_scene: ID of a scene after which this one plays automatically.
  • resume_after_flag: flag name. The scene waits until that flag is set.
  • messages_in: list of incoming messages.
  • choices: list of choices presented to the player.
  • free_input: variable name to capture free text input from the player.
  • next: ID of the next scene when free_input is used.
  • music: Godot path to an audio file to play as background music. Optional โ€” three possible behaviors:
  • Absent: the current music continues uninterrupted.
  • Path ("res://assets/music/tension.ogg"): plays this track on loop. No effect if the same track is already playing.
  • null: fades out and stops the current music.
  • end: true to end the game after this scene. The engine emits the game_ended signal instead of looking for a next scene. See End Screen.

Music automatically ducks when the player plays an audio message, then fades back up when playback ends. The player can adjust music volume independently in the game settings (Music volume slider).

4. Incoming Messages (messages_in) ๐ŸŸข

Short Form

A simple message can be written as a string:

"Hello?"

The engine automatically converts a string into { "text": "..." }.

Full Form

{
  "text": "I'm lost.",
  "pause": "short",
  "requires_flag": "called_for_help",
  "condition": { "var": "trust", "op": "gte", "value": 2 },
  "effects": [ ... ],
  "media": { "type": "image", "path": "res://assets/images/location.png" },
  "time": "14:43"
}

Available Fields

  • text: message content. Can be null if a media file is sent instead. Also accepts an array of strings to chain multiple bubbles in a single declaration โ€” see below.
  • pause: short, medium, long.
  • requires_flag: message shown only if the flag is set. Can be a string (single flag) or an array of strings (all flags must be set).
  • condition: condition based on a numeric variable.
  • edit: modifies the message after it is sent.
  • corrupted: displays the bubble as a corrupted message โ€” โœ— Corrupted message in red. text is not required.
  • effects: effect triggered immediately when the message appears.
  • media: image or audio attachment.
  • time: optional timestamp displayed below the bubble. "HH:MM" โ†’ shown as-is (same-day message). "YYYY-MM-DD HH:MM" โ†’ shown as "DD-MM-YYYY HH:MM" (FR locale) or "YYYY-MM-DD HH:MM" (other locales) if the date is before today.

Editing a Message After Sending (edit)

A message can correct itself or be deleted automatically after a delay โ€” as if the contact noticed a typo or thought better of what they wrote.

Correction:

{
  "text": "I have no idea where i am.",
  "edit": { "type": "correct", "corrected_text": "I have no idea where I am.", "delay": 2.0 }
}

Deletion:

{
  "text": "Never mind.",
  "edit": { "type": "delete", "delay": 3.0 }
}
  • type: correct to replace the text, delete to replace the bubble with "Message deleted".
  • corrected_text: the new text to display (required if type is correct).
  • delay: time in seconds before the edit occurs. Optional โ€” defaults to 1.5.

edit also accepts an array to chain multiple operations. Each delay is relative to the previous operation:

{
  "text": "Never mind, I'm fine.",
  "edit": [
    { "type": "correct", "corrected_text": "Never mind...", "delay": 2.0 },
    { "type": "delete", "delay": 3.0 }
  ]
}

The bubble's final state is stored in conversation history, so a correction or deletion remains visible after saving, switching contacts, and loading the game again.

Corrupted Message (corrupted)

A message can arrive in a corrupted state. The typing indicator appears as normal, then instead of text the bubble displays โœ— Corrupted message in red.

Useful to simulate a failed transmission, a jammed signal, or an intentionally incomplete message.

{
  "id": "scene_weak_signal",
  "messages_in": [
    { "text": "I'm going to try sending the photoโ€”" },
    { "corrupted": true },
    { "text": "Did you get anything?", "pause": "short" }
  ],
  "choices": [
    { "text": "No, nothing came through.", "message": "No, nothing.", "next": "scene_retry" },
    { "text": "Something arrived but it's unreadable.", "message": "I got something but it's completely corrupted.", "next": "scene_retry" }
  ]
}

Like any other message, corrupted accepts pause, requires_flag, condition, and effects:

{ "corrupted": true, "pause": "short", "requires_flag": "weak_signal" }

Bubble Array

When text is an array, the engine automatically expands it into multiple separate message bubbles:

{
  "text": ["...", "That's not spam!", "I'm a real person."],
  "requires_flag": "rep_a",
  "pause": "short"
}

Expansion rules: - requires_flag and condition apply to all bubbles - pause and effects apply to the first bubble only - time applies to the last bubble only

To add a pause on a specific bubble, replace the string with an object { "text": "...", "pause": "short" }:

{
  "text": [
    "Thank you for staying with me!",
    { "text": "It's reassuring to have someone on the outside...", "pause": "short" },
    "But I'm really scared."
  ],
  "requires_flag": "rep_b1"
}

Strings and objects can be freely mixed in the same array. The parent's requires_flag applies to all bubbles regardless.

5. Media Messages ๐ŸŸก

Image

{
  "text": null,
  "media": { "type": "image", "path": "res://assets/images/location.png" }
}

Important: - Image files must be placed in the assets/images/ folder. - In JSON, always use a Godot path starting with res://assets/images/.

Audio

{
  "text": null,
  "media": { "type": "audio", "path": "res://assets/sounds/voicenote.ogg" }
}

Important: - Audio files must be placed in the assets/sounds/ folder. - In JSON, always use a Godot path starting with res://assets/sounds/.

6. Choices (choices) ๐ŸŸข

A choice is an object with at least a text field.

{
  "text": "I'll help you.",
  "message": "I'll help you.",
  "next": "scene_02",
  "flag": "commitment_a",
  "effects": [ ... ]
}

Notes

  • next: ID of the scene to play after the player confirms this choice. Required in most cases.
  • message can be a string or an array of strings.
  • If message is an array, each element is sent as a separate bubble by the player.
  • flag activates a boolean flag.
  • effects applies variable changes or contact modifications.
  • requires_flag and condition control choice visibility โ€” a choice whose condition is not met will not appear in the list. The same syntax as for messages is supported.

Maximum 4 choices per scene. The engine renders up to 4 choice buttons at a time. Entries beyond the fourth are silently ignored โ€” no error is raised.

Warning: if all choice conditions are false at the same time, the player will be stuck with nothing to click. Always ensure at least one choice is visible โ€” either by leaving it without a condition, or by making sure all cases are covered.

Multi-bubble Message Example

{
  "text": "Hmmโ€ฆ curious though.",
  "message": [
    "I'm not convincedโ€ฆ",
    "But I'm curious to see where this goes."
  ],
  "next": "scene_02"
}

In this example, the choice displays the label Hmmโ€ฆ curious though., then the player sends two messages in sequence.

Pause Between Bubbles in a Choice

In a message array, each item can be either a string or an object { "text": "...", "pause": "short" }. This lets you add a pause before the next bubble, exactly like in messages_in.

{
  "text": "We'll figure it out.",
  "message": [
    "Don't worry, you're going to get out of there!",
    { "text": "Together we'll find something.", "pause": "short" },
    "Any idea how to get some light in there?"
  ],
  "next": "next_scene"
}

Accepted values for pause: short, medium, long โ€” same durations as for incoming messages.

7. Effects ๐ŸŸก

Effects are declared in the effects field of a message or a choice and applied immediately.

Important: effects is always nested inside a message or choice โ€” never at the scene level. A field like "set_status": "..." placed directly on the scene object will be silently ignored by the engine.

Supported Operations

  • set: sets a variable to a fixed value.
  • add: adds a value to a variable.
  • sub: subtracts a value from a variable.
  • rename: changes a contact's display name. value accepts either a plain string or a language-keyed dict (see below).
  • set_status: changes a contact's status. Accepted values: online, away, offline, network_issue.

Examples

"effects": [
  { "op": "set", "var": "trust", "value": 1 },
  { "op": "add", "var": "stress", "value": 2 },
  { "op": "rename", "contact": "unknown", "value": "Maeve" }
]

Localized rename

When the revealed name must itself be translated (e.g. a title like "The Guardian"), pass a language-keyed dict as value instead of a plain string:

{ "op": "rename", "contact": "unknown", "value": { "fr": "Le Gardien", "en": "The Guardian" } }

The engine resolves the dict against the active language when the effect fires. If the active language has no matching key, the first value in the dict is used as the fallback. A confirmed language change restarts the game, so the name is resolved again when this effect is replayed.

A plain string (e.g. "Maeve") remains the right choice for proper names that are the same in all languages โ€” it takes precedence over all locale-specific values.

8. Variables and Conditions ๐ŸŸก

Variables

Variables are numeric and stored in vars.

Multiple Flags (AND)

requires_flag accepts a string or an array. With an array, all flags must be set:

"requires_flag": ["rep_a", "commit_t"]

Simple Condition

"condition": { "var": "trust", "op": "gte", "value": 2 }

Supported operators: eq, neq, gt, gte, lt, lte.

Compound Conditions

condition can use and and or operators with nested nodes.

Each node can be: - { "flag": "flag_name" } โ€” checks a flag - { "var": "...", "op": "...", "value": ... } โ€” compares a variable - { "and": [...] } or { "or": [...] } โ€” sub-expression - { "not": { ... } } โ€” negates any node

AND between a flag and a variable:

"condition": {
  "and": [
    { "flag": "rep_a" },
    { "var": "trust", "op": "gte", "value": 3 }
  ]
}

OR between two flags:

"condition": {
  "or": [
    { "flag": "react_r" },
    { "flag": "react_u" }
  ]
}

NOT โ€” message shown only if flag is NOT set:

"condition": { "not": { "flag": "replied_maman" } }

Nested:

"condition": {
  "and": [
    { "flag": "commit_t" },
    { "or": [
        { "var": "stress", "op": "lt", "value": 5 },
        { "flag": "react_t" }
      ]
    }
  ]
}

9. free_input ๐ŸŸก

Lets the player type a free-text response.

{
  "id": "scene_capture",
  "messages_in": ["What's your name?"],
  "free_input": "player_name",
  "free_input_placeholder": "Enter your first nameโ€ฆ",
  "next": "scene_response"
}
  • free_input: name of the variable where the entered value is stored. The name is up to you โ€” "first_name", "secret_code", "answer" are all valid.
  • free_input_placeholder: text displayed in the input field before the player types. Optional โ€” falls back to the default placeholder if absent.

The entered value can then be injected into any message text via templates (see next section): "So {first_name}, what were you doing that night?"

10. Templates ๐ŸŸก

Variable values can be injected into message text using curly braces:

"text": "Thank you {player_name}, that's reassuring."

11. Secondary Contacts ๐ŸŸก

When a scene has a contact_id different from the currently active contact, the engine plays it in the background: messages are added to that contact's history, a notification badge appears in the panel, and the player decides when to switch and read the conversation.

This is the main mechanism for multi-contact stories. Example: Maeve is the main contact, Alex sends a message while the player is reading Maeve's conversation. When the player switches to Alex, the messages appear and any pending choices are shown โ€” a full branching conversation is possible, exactly like the main contact.

{
  "id": "alex_interrupts",
  "contact_id": "alex",
  "trigger_after_scene": "scene_03",
  "messages_in": ["Did you see the news?"]
}

This scene triggers automatically after scene_03 and arrives in Alex's conversation, not Maeve's.

12. Triggers and Deferred Scenes ๐Ÿ”ด

  • trigger_after_scene: the scene plays automatically after the given scene ID finishes.
  • resume_after_flag: the scene is deferred until the specified flag is set.
  • resume_after_delay: the scene plays after a real-time delay. The engine records the target time in the save file โ€” if the game is relaunched in between, the scene plays immediately on load if the delay has passed, or resumes the countdown otherwise.

Accepted formats for resume_after_delay: - Number of seconds: 300 - String with suffix: "5m", "1h", "30s"

Pattern 1 โ€” Delay triggered by a choice

The contact announces they're leaving, the player replies, and the next message only arrives one hour later in real time.

[
  {
    "id": "maeve_leaves",
    "contact_id": "maeve",
    "messages_in": [
      "I need to deal with something urgent.",
      { "text": "I'll message you again in an hour.", "pause": "short" }
    ],
    "choices": [
      {
        "text": "Ok, take your time.",
        "message": "Ok, take all the time you need.",
        "next": "maeve_returns"
      }
    ]
  },
  {
    "id": "maeve_returns",
    "contact_id": "maeve",
    "resume_after_delay": "1h",
    "messages_in": [
      "I'm back.",
      { "text": "Sorry for the wait.", "pause": "short" }
    ],
    "choices": [
      {
        "text": "No worries at all.",
        "message": "No worries at all.",
        "next": "next_scene"
      }
    ]
  }
]

When the player selects a choice in maeve_leaves, the engine tries to play maeve_returns โ€” but it has a one-hour delay. The engine records the target time and stops. One hour later (game open or relaunched), maeve_returns plays automatically.

Pattern 2 โ€” Delay on an automatically triggered scene

No intermediate choice needed here. The scene triggers at the end of another via trigger_after_scene, but only arrives after a delay.

[
  {
    "id": "scene_03",
    "contact_id": "maeve",
    "messages_in": ["I'll send you the info tonight."],
    "choices": [
      {
        "text": "Ok, I'll wait.",
        "message": "Ok, I'll wait.",
        "next": "scene_04"
      }
    ]
  },
  {
    "id": "maeve_evening",
    "contact_id": "maeve",
    "trigger_after_scene": "scene_03",
    "resume_after_delay": "3h",
    "messages_in": [
      "Done, I sent everything.",
      { "text": "Let me know if you got it.", "pause": "short" }
    ],
    "choices": [...]
  }
]

maeve_evening triggers automatically at the end of scene_03, but its 3-hour delay is applied first โ€” the player will receive the message 3 hours later, even if they closed the game.

Note: resume_after_delay works with any contact (contact_id). A secondary contact scene with a delay will arrive in the right conversation at the right time, with its notification badge.

Pattern 3 โ€” Scene that plays when the player opens a contact

When the player switches to a contact, the engine automatically sets the flag opened_{contact_id}. Combine this with resume_after_flag to trigger a scene the moment (or shortly after) the player opens that conversation.

The key difference from a background scene: because the player is already looking at the conversation when the flag fires, the scene plays live (with typing indicator, animations, and edit effects) rather than being silently added to history.

[
  {
    "id": "scene_mom_01",
    "contact_id": "mom",
    "trigger_after_scene": "some_earlier_scene",
    "messages_in": [
      { "text": "Did you arrive safely?" }
    ],
    "choices": [...]
  },
  {
    "id": "scene_mom_02",
    "contact_id": "mom",
    "trigger_after_scene": "scene_mom_01",
    "resume_after_flag": "opened_mom",
    "messages_in": [
      { "text": "...", "pause": "short", "edit": { "type": "delete", "delay": 2 } }
    ]
  }
]

scene_mom_02 queues up after scene_mom_01. When the player opens the mom conversation, the flag opened_mom is set, and scene_mom_02 plays immediately โ€” animated, in real time, with the delete effect firing after 2 seconds.

Note: the flag opened_{contact_id} is set every time the player switches to that contact. If the scene has already played, it won't replay โ€” resume_after_flag is consumed on first use.

13. Validation ๐ŸŸข

The game automatically validates story.json and all dialogues/*.json files on launch in Godot.

If errors or warnings are found, a window appears immediately in the game with a full breakdown. Errors are also logged to the Godot console.

No tools to install: just open the project in Godot and read the report that appears.

Errors vs warnings

Both cause the validation window to appear on launch.

Type Examples Consequence
Error Scene not found (next, trigger_after_scene, start_scene) ยท Contact not found ยท messages_in missing ยท resume_after_flag whose flag is never set (deadlock) ยท Trigger cycle ยท Required field missing in an effect or condition ยท Duplicate scene ID Structural problem โ€” the scene cannot function
Warning requires_flag referencing a flag never set ยท Choice without next (narration stops) ยท Silent message with no effect or pause (will be skipped) ยท free_input without next ยท Unknown pause value ยท Unknown effect op Likely an authoring oversight โ€” the game runs but the behaviour is suspicious

14. Localizing Dialogues ๐Ÿ”ด

The engine supports multiple languages through separate dialogue files.

Naming Convention

dialogues/
โ”œโ”€โ”€ acte1.json        โ† base file (loaded when no locale-specific variant exists)
โ”œโ”€โ”€ acte1.fr.json     โ† French variant
โ””โ”€โ”€ acte1.en.json     โ† English variant

On startup, the engine automatically selects the file matching the active language. If no variant exists for the current locale, it falls back to the base file (no suffix).

Per-locale Start Scene

If your story has a different entry point per language (different prologue, entirely distinct scene structureโ€ฆ), you can declare a start_scene directly inside the localized dialogue file โ€” it takes priority over the one in story.json:

{
  "start_scene": "ch1_intro",
  "scenes": [
    { "id": "ch1_intro", ... },
    ...
  ]
}

If the file has no start_scene, the value defined in story.json is used.

Language Change Mid-Game

When the player selects another language from Settings:

  • if no save exists, dialogue files are reloaded and the story starts in the selected language;
  • if a game is in progress, a confirmation warns that changing language will reset progress;
  • if the player confirms, the narrative save is deleted, localized files are reloaded, and the story restarts from its starting scene;
  • if the player cancels, both the save and the previously active language are preserved.

A confirmed change therefore does not preserve message history, flags, variables, contact statuses, or narrative renames. Other player settings, such as volume and display options, remain saved.

Adding a Language

  1. Duplicate the base file: acte1.json โ†’ acte1.es.json
  2. Translate all text, message, and choices[].text fields
  3. Keep all IDs (id, next, flag, requires_flag) identical โ€” these are internal keys, not displayed text

UI Translations

Interface texts (statuses, buttons, validation messages) are managed separately in translations/ui.csv. To add a language, add a column with the ISO language code (es, de, etc.) and fill in all keys.

keys,en,fr,es
STATUS_ONLINE,online,en ligne,en lรญnea
BTN_CANCEL,Cancel,Annuler,Cancelar

The system language is detected automatically on first launch. Players can change it via the Settings menu (โš™).

15. Debug Tool โ€” Jump to Scene ๐ŸŸข

A debug overlay is built into the engine to make testing easier โ€” no need to replay from the beginning every time.

Access

Press F9 during the game to open or close the overlay.

The overlay is only available in the Godot editor and Debug exports. It is automatically absent from Release exports โ€” no cleanup required before publishing.

Usage

The overlay has three sections:

  • Scene ID โ€” type the exact ID of any scene (e.g. scene_05). The field flashes red briefly if the ID is invalid.
  • Flags โ€” a list of every flag known to the project. Check the ones the target scene depends on before jumping.
  • Vars โ€” variables to inject, one per line in key=value format. Integers and floats are detected automatically; anything else is stored as a string (useful for pre-filling a free_input variable before jumping to the next scene):
trust=3
stress=1
player_name=Alice

Click Jump to apply the state and play the scene. The current conversation is replaced immediately.

Close (or F9) dismisses the overlay without changing any game state.

Values injected via F9 (flags, variables, vars) are not saved โ€” they disappear if you relaunch the game without going through the overlay again.

16. Main Menu ๐ŸŸก

The main menu is fully configured from story.json and theme.json โ€” no code changes needed.

Title

The title field in story.json sets the text displayed prominently on the main menu.

{
  "title": "My Story",
  "start_scene": "intro",
  "contacts": [ ... ]
}

If the field is absent, the title is left empty.

Glitch effect

By default, the title plays a glitch animation on load: characters appear as noise, then decode progressively, with random corruptions at idle.

To disable the effect, add "title_glitch": false in theme.json:

{
  "title_glitch": false
}
Value Behaviour
true (default) Decode animation on load + random idle glitches
false Static title, displayed immediately

17. End Screen ๐ŸŸก

When a scene contains "end": true, the engine displays an end screen instead of continuing to the next scene.

Marking a scene as the end

{
  "id": "scene_final",
  "messages_in": [
    { "text": "See you soon." }
  ],
  "end": true
}

"end": true is compatible with messages_in and choices โ€” the scene plays normally, then the end screen appears. It is incompatible with next and trigger_after_scene (both ignored when end is present).

Configuring the screen (end_screen in story.json)

{
  "title": "...",
  "start_scene": "...",
  "contacts": [ ... ],
  "end_screen": {
    "title": "CONNECTION TERMINATED",
    "text": "More coming soon.",
    "link_url": "https://itch.io/your-game",
    "link_label": "Learn more",
    "glitch": true,
    "show_stats": true
  }
}

All fields are optional. If end_screen is absent from story.json, a minimal screen is shown with only the New Game and Quit buttons.

Field Type Default Description
title string or localized dict "CONNECTION TERMINATED" Main text shown large, monospace font. Accepts {"fr": "...", "en": "..."} for localized text.
text string or localized dict (absent) Secondary text below the title โ€” teaser, coming soon announcement, etc. Accepts {"fr": "...", "en": "..."} for localized text.
link_url string (absent) URL opened on click. If absent, no link is shown
link_label string (raw URL) Text shown on the link. If absent, the URL is shown directly
glitch bool false Enables the glitch effect: text scramble on the title + animated scanlines + flicker
show_stats bool false Shows the number of messages exchanged during the session

Glitch effect

When "glitch": true, three effects combine:

  • Text scramble โ€” the title characters are periodically replaced with noise, then restored (same algorithm as the main menu title)
  • Animated scanlines โ€” slow-drifting horizontal light bands across the screen
  • Flicker โ€” the screen blinks randomly at low intensity

Editing via the Story Editor

The Contacts panel in the Story Editor exposes an End screen section with all configurable fields โ€” no JSON file to open.


Appendix A โ€” Emoji shortcuts

Emoji work in any text field: text, message, free_input_placeholder.

Copy-paste

Paste the emoji character directly into the JSON โ€” no special encoding needed.

{ "text": "That made me laugh ๐Ÿ˜‚" }

Text shortcuts

If you can't copy-paste an emoji, use the standard text shortcuts โ€” the engine converts them automatically on display.

Shortcut Emoji Shortcut Emoji
:) ๐Ÿ˜Š :-) ๐Ÿ˜Š
:D ๐Ÿ˜„ :-D ๐Ÿ˜„
:( ๐Ÿ˜ข :-( ๐Ÿ˜ข
;) ๐Ÿ˜‰ ;-) ๐Ÿ˜‰
:P ๐Ÿ˜› :-P ๐Ÿ˜›
:O ๐Ÿ˜ฎ :-O ๐Ÿ˜ฎ
:* ๐Ÿ˜˜ :-* ๐Ÿ˜˜
:/ ๐Ÿ˜• :-/ ๐Ÿ˜•
:\| ๐Ÿ˜ :-\| ๐Ÿ˜
:'( ๐Ÿ˜ญ :') ๐Ÿฅฒ
>:( ๐Ÿ˜  >:) ๐Ÿ˜ˆ
O:) ๐Ÿ˜‡ B) ๐Ÿ˜Ž
=D ๐Ÿ˜ XD ๐Ÿ˜†
^^ ๐Ÿ˜„ ^_^ ๐Ÿ˜Š
T_T ๐Ÿ˜ญ -_- ๐Ÿ˜‘
>_< ๐Ÿ˜ฃ o.O ๐Ÿคจ
<3 โค๏ธ </3 ๐Ÿ’”

Parenthesis shortcuts (Teams style)

Shortcut Emoji Shortcut Emoji
(y) ๐Ÿ‘ (n) ๐Ÿ‘Ž
(ok) ๐Ÿ‘Œ (clap) ๐Ÿ‘
(wave) ๐Ÿ‘‹ (punch) ๐Ÿ‘Š
(muscle) ๐Ÿ’ช (flex) ๐Ÿ’ช
(handshake) ๐Ÿค (pray) ๐Ÿ™
(facepalm) ๐Ÿคฆ (shrug) ๐Ÿคท
(hug) ๐Ÿค— (bow) ๐Ÿ™‡
(eyes) ๐Ÿ‘€ (heart) โค๏ธ
(brokenheart) ๐Ÿ’” (love) ๐Ÿ˜
(think) ๐Ÿค” (laugh) ๐Ÿ˜‚
(lol) ๐Ÿ˜‚ (rofl) ๐Ÿคฃ
(yum) ๐Ÿ˜‹ (cool) ๐Ÿ˜Ž
(nerd) ๐Ÿค“ (crazy) ๐Ÿคช
(monocle) ๐Ÿง (sweat) ๐Ÿ˜…
(nervous) ๐Ÿ˜ฌ (sleepy) ๐Ÿ˜ด
(sick) ๐Ÿค’ (zip) ๐Ÿค
(skull) ๐Ÿ’€ (poop) ๐Ÿ’ฉ
(fire) ๐Ÿ”ฅ (100) ๐Ÿ’ฏ
(star) โญ (sparkle) โœจ
(check) โœ… (tick) โœ…
(cross) โŒ (warning) โš ๏ธ
(idea) ๐Ÿ’ก (money) ๐Ÿ’ฐ
(time) โฐ (zzz) ๐Ÿ’ค
(coffee) โ˜• (beer) ๐Ÿบ
(pizza) ๐Ÿ• (cake) ๐ŸŽ‚
(gift) ๐ŸŽ (music) ๐ŸŽต
(phone) ๐Ÿ“ฑ (email) ๐Ÿ“ง
(tada) ๐ŸŽ‰ (party) ๐ŸŽŠ
(trophy) ๐Ÿ† (medal) ๐Ÿ…
(book) ๐Ÿ“– (computer) ๐Ÿ’ป
(laptop) ๐Ÿ’ป (chart) ๐Ÿ“ˆ
(cat) ๐Ÿฑ (dog) ๐Ÿถ
(sun) โ˜€๏ธ (moon) ๐ŸŒ™
(snow) โ„๏ธ (rainbow) ๐ŸŒˆ
(flag) ๐Ÿšฉ (lock) ๐Ÿ”’
(key) ๐Ÿ”‘ (bell) ๐Ÿ””
(skull) ๐Ÿ’€ (!) โ—
(?) โ“