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
- 1. Overview ๐ข
- 2. story.json ๐ก
- 3. Dialogue File ๐ข
- 4. Incoming Messages ๐ข
- 5. Media Messages ๐ก
- 6. Choices ๐ข
- 7. Effects ๐ก
- 8. Variables and Conditions ๐ก
- 9. free_input ๐ก
- 10. Templates ๐ก
- 11. Secondary Contacts ๐ก
- 12. Triggers and Deferred Scenes ๐ด
- 13. Validation ๐ข
- 14. Localizing Dialogues ๐ด
- 15. Debug Tool โ Jump to Scene ๐ข
- 16. Main Menu ๐ก
- 17. End Screen ๐ก
- Appendix A โ Emoji shortcuts
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 useresume_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:trueto designate the main contact โ the character the engine treats as the central interlocutor when no special configuration is set. Only one contact can haveis_main: true; the Story Editor automatically unchecks all others when you select one.avatar: path to the contact's avatar image, ornull. 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
namefor the entire session. - If the active language has no key in
names(untranslated language, ornamesabsent),nameis 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:
namesonly controls the name shown in the top bar and contact list before any narrative rename occurs. If a scene renames the contact via arenameeffect, the renamed name takes over for the rest of the session. For narrative renames that also need to be translated, therenameeffect supports a localized dict as itsvalueโ 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:
historyis not a played scene. No flag, variable, or effect is triggered byhistoryentries. 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 whenfree_inputis 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:trueto end the game after this scene. The engine emits thegame_endedsignal 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 benullif 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.textis 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:correctto replace the text,deleteto replace the bubble with "Message deleted".corrected_text: the new text to display (required iftypeiscorrect).delay: time in seconds before the edit occurs. Optional โ defaults to1.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.messagecan be a string or an array of strings.- If
messageis an array, each element is sent as a separate bubble by the player. flagactivates a boolean flag.effectsapplies variable changes or contact modifications.requires_flagandconditioncontrol 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:
effectsis 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.valueaccepts 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_delayworks 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_flagis 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
- Duplicate the base file:
acte1.jsonโacte1.es.json - Translate all
text,message, andchoices[].textfields - 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=valueformat. Integers and floats are detected automatically; anything else is stored as a string (useful for pre-filling afree_inputvariable 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) |
๐ | (!) |
โ | |
(?) |
โ |