stableEngine v0.4.0 Updated 26 Aug 2026, 12:00

Subtext story format

This is the authoritative reference for the subtext-0.4.0 format shared by editable Studio Projects and Published stories. It is plain text, Yarn-inspired, readable by writers, editable by hand and suitable for LLMs. It is declarative and never executes GDScript, Python or arbitrary code.

The format identifier uses semantic versioning. Writers and Studio always emit the canonical subtext-0.4.0 identifier. Older MINOR/PATCH revisions with the same first version number are read and normalized to the current format; changing that first number is the explicit breaking boundary. The runtime still reads the former subtext-2 identifier as a legacy alias, normalizes it to subtext-0.4.0 in memory, and writes the canonical identifier on the next serialization. Malformed versions, newer revisions and unsupported major versions are rejected explicitly.

That syntax-format version is independent from the Story release revision stored in metadata. A Story uses MAJOR.MINOR without PATCH because it distinguishes save-affecting structure from smaller immutable content updates; it does not describe application or file-format compatibility.

For the visual authoring interface built on top of this format, see the Studio guide.

Studio can copy and load text at three levels:

  • the complete story;
  • one chapter and all its conversations;
  • one conversation.

Package structure

An editable Studio Project lives under user://story_projects/<package>/:

user://story_projects/my_story/
|-- story.phone
|-- migrations.phone          (optional)
|-- .subtext-project.json
|-- .published/               (after publication)
|   |-- v1.0.subtext
|   `-- v1.1.subtext
`-- assets/
	|-- cover.png
	|-- wallpaper.png
	|-- maya.png
	|-- beach.webp
	`-- voice.ogg

Editable .subtext-project transfer bundle

This subsection is the normative contract for tools that generate an editable Project without going through Studio. A .subtext-project file is a ZIP archive with a different extension. Archive paths use forward slashes and are relative to the archive root. Do not put the files inside a wrapping directory:

example.subtext-project
|-- story.phone
|-- .subtext-project.json
|-- migrations.phone              (optional)
|-- assets/                       (optional)
|   `-- cover.png
`-- .published/                   (only when preserving an existing publication)
	`-- v1.0.subtext

story.phone and .subtext-project.json must be files directly at the root. The external archive filename is cosmetic and does not have to equal the Story id. ZIP directory entries such as assets/ may be present or omitted. Studio's exporter currently writes a normal Deflate-compressed ZIP. The importer does not impose a compression level or inspect the method separately; generators should use standard ZIP/Deflate for maximum compatibility with Godot's ZIPReader.

The only file paths supported by an editable bundle are:

Archive pathRequirement
story.phoneRequired UTF-8 story source.
.subtext-project.jsonRequired UTF-8 JSON object described below.
migrations.phoneOptional UTF-8 migration source. It is preserved in editable imports even when it needs repair, but publication validates it strictly.
assets/**Optional media whose extension is png, jpg, jpeg, webp, gif, ogg, wav, mp3, or ogv, case-insensitively.
.published/**/*.subtextOptional frozen releases referenced by a publication record in the manifest.

Any other non-directory entry makes the bundle invalid. Asset and publication paths must not contain ../ or :. A parent directory such as example/story.phone, sidecar files such as README.txt, and Godot import metadata such as asset.png.import are therefore not allowed. The importer validates path safety and extensions, but it does not inspect an asset's magic bytes, dimensions, duration, or ability to decode. Generators should validate real media before packaging it rather than relying on import to do so. Unreferenced supported assets are allowed in an editable Project.

The minimal .subtext-project.json written by the current exporter is:

{
  "format": "subtext_studio_project",
  "manifest_version": 2,
  "package_type": "studio_project",
  "project_contract": "editable_source_bundle_v1",
  "story_id": "community.example",
  "story_version": "1.0"
}

All six fields are required for a newly generated, unpublished Project:

FieldRequired value or rule
formatExact string subtext_studio_project.
manifest_versionInteger 2 for new bundles. Readers also accept legacy version 1.
package_typeExact string studio_project.
project_contractExact string editable_source_bundle_v1.
story_idMust exactly equal the id metadata in story.phone.
story_versionMust equal the normalized version metadata in story.phone. Use the canonical MAJOR.MINOR string, normally 1.0 for a new Project.

Story release versions contain positive MAJOR and non-negative MINOR decimal components. An old integer value such as 5 is read as 5.0, but generators must emit the canonical string. Do not confuse this release revision with the independent format: subtext-0.4.0 syntax version inside story.phone. There is no file list and there are no per-file hashes in the minimal editable manifest. The current reader ignores unknown top-level manifest keys, but generators should omit them because they are not part of the contract and may acquire meaning in a later manifest version.

A Project that has already published a playable revision carries additional publication state:

{
  "format": "subtext_studio_project",
  "manifest_version": 2,
  "package_type": "studio_project",
  "project_contract": "editable_source_bundle_v1",
  "story_id": "community.example",
  "story_version": "1.1",
  "published_version": "1.0",
  "publish_contract": "immutable_id_version_content_v1",
  "draft_fingerprint": "<sha256>",
  "public_contract": {},
  "contract_fingerprint": "<sha256>",
  "artifact": {
    "path": ".published/v1.0.subtext",
    "size": 12345,
    "content_hash": "<sha256>"
  }
}

Do not synthesize that state for a new Project. When published_version is present and non-empty, it must not be newer than story_version; publish_contract, draft_fingerprint, public_contract, and a safe .published/*.subtext artifact record are required. The referenced frozen artifact must be in the archive and its exact byte size and SHA-256 must match. contract_fingerprint is retained when present. Removing publication state intentionally turns the transfer into an unpublished editable Project; it must not be used to pretend that an already distributed revision has a different history.

Minimal bundle-generation recipe

An automated generator or LLM-backed tool should follow this order:

  1. Produce story.phone as UTF-8 with format: subtext-0.4.0, a non-empty title and id, canonical version: 1.0, a player character, and one included initial chapter.
  2. If it creates a conversation, give it an existing chapter, include player in participants, and add at least one dialogue or command block. An empty conversation is invalid.
  3. Put every binary dependency below assets/, use one of the supported extensions, and reference it with the same case-sensitive forward-slash path from the source.
  4. Build the six-field unpublished manifest above. Copy id and normalized version exactly; do not invent hashes, a files array, or publication fields.
  5. Create a ZIP whose root entries include story.phone and .subtext-project.json; rename it with the .subtext-project extension. Do not add duplicate paths, a wrapping folder, or tool-generated sidecars.
  6. Reopen the finished archive, compare its non-directory entry names with the intended list, decode both text files as UTF-8, parse the JSON, and validate story.phone before offering the bundle.

This is a complete minimal source with one chapter, one conversation, one PNG reference, and an emoji:

@story
id: community.minimal_example
format: subtext-0.4.0
story: Minimal Example
version: 1.0
cover: assets/cover.png
content_tags: sfw

@characters
player:
	name: Me
maya:
	name: Maya

@chapter intro
name: Introduction

=== hello ===
participants: player, maya
---
maya: Hello! 👋
===

The PNG bytes must separately be stored at assets/cover.png. Display text and dialogue are Unicode and may contain emojis. Unicode asset filenames pass the current path checks, but ASCII filenames are the most portable choice across ZIP tools and filesystems. The external bundle filename and asset filenames do not have to match the Story id. Internal identifiers should use lowercase ASCII: start with a-z or _, then use only a-z, digits, _, -, and .. The Story id must be non-empty and must not be the reserved value __app.

If the requested id already exists in Studio, import creates an independent id with _2, _3, and so on, rewrites the source and manifest to that id, resets its version to 1.0, and does not copy the old migrations.phone or .published/ history.

Bundle import diagnostics

At the API boundary a failed import returns a dictionary with ok: false, a Godot error code, and a human-readable message. Studio shows the selected file name and message in a Project import failed report, and logs it as Unable to import Studio project: <message>. Package parsing currently returns only the first story.phone parser message through this path, without its line number. Common exact messages are:

This file is not a readable .subtext package.
This package has no story.phone source.
This .subtext-project bundle has no project manifest.
This file is not a supported editable Subtext project.
Project story id does not match its manifest.
Project story version does not match its manifest.
Project published version is invalid.
Project publication record is incomplete.
The package contains an unsafe or unsupported file: <path>
The project bundle's frozen published artifact is missing or changed.

When debugging, inspect the archive entry names first, then the manifest constants and identity match, then parse story.phone, and finally verify publication artifact hashes if publication state exists.

An installed Published story lives separately under user://published_stories/<package>/ and is read-only. Its folder contains story.phone, optional migrations.phone, its required assets/, and an internal .subtext-published.json integrity manifest. Studio transfers editable Projects as .subtext-project bundles; Publish Story creates immutable .subtext releases, which players install only from Play Stories > Community.

On publication, referenced media receives a content-addressed path under assets/_published/. Later revisions retain every earlier published asset entry, so a saved message or phone-app snapshot continues to resolve the same bytes even when the editable file at its original path is replaced. The optional root migrations.phone file is included in both bundle types and in the installed integrity manifest.

The installed manifest uses SHA-256 to detect accidental or local content changes and rejects different bytes that reuse the same story id and revision. It is not a signature, publisher-authenticity system, DRM mechanism, or protection against a deliberately rebuilt package; those trust features are deferred.

Official story packages live under res://stories/stories/<package>/, with their Godot configuration resources in res://stories/originals/. The old user://stories/ root is scanned only for editable Projects created before the Project/Published split; it is not a player-facing Community story library.

For an Official story, story.phone remains the live Studio draft, while Publish Story atomically copies the latest frozen release to _published/runtime.subtext. Runtime loads source and migrations from that pinned package and resolves its content-addressed media from the Official's assets folder. Exported builds ignore an Official that has no pinned release, so every shippable Original must be published at least once. Studio continues to read the live draft. Community packages and editable Projects keep their separate layouts above; there is no generated JSON story copy.

Supported story assets are PNG, JPG, JPEG, WebP, GIF, OGG, WAV, MP3 and OGV. Paths are package-relative and use forward slashes, for example assets/maya.png. Studio accepts animated GIF files for avatars, story covers, wallpapers, and authored images. Static avatars and covers can be cropped; GIF files are kept uncropped to preserve their animation.

Syntax conventions

  • Metadata uses key: value.
  • @story, @characters and @chapter <id> open the main document sections.
  • === conversation_id === opens a conversation and === closes it.
  • --- separates a conversation header from its dialogue body.
  • Dialogue uses character: text.
  • Choices begin with -->, end with :, and their consequences are indented by four spaces.
  • Game actions use Yarn-like commands. A short command keeps named properties on one line, for example <<photo from:amy file:assets/selfie.png>>.
  • A command with several properties opens with <<command, lists key: value properties, and closes explicitly with >>. Its properties do not depend on indentation.
  • An optional trailing #id:block_id names a block that must be referenced elsewhere.
  • Lines beginning with # are comments.
  • Blank lines are ignored.
  • Values containing :, #, leading whitespace or newlines are quoted automatically by Studio.
  • Identifiers use lowercase letters, numbers, _, - and . without spaces; they begin with a letter or _.

Most flow is implicit: the next unindented line runs next. Explicit flow commands are only necessary for non-linear graphs:

<<jump another_block>>
<<return block_after_branch>>
<<stop>>

jump changes the next block in the current sequence. return exits a choice or condition branch into the main conversation. stop ends the current sequence or branch.

The same command can be written in a compact or expanded form:

<<photo from:amy file:assets/selfie.png gallery:true>>

<<photo
	from: amy
	file: assets/selfie.png
	gallery: true
>>

Use the compact form when it stays easy to scan, and the expanded form for richer actions. Quotes are optional for simple values and required when a value contains spaces or syntax characters: title:"Wrong answer". Studio always exports one canonical representation.

Complete minimal story

@story
id: community.a_new_story
format: subtext-0.4.0
story: A New Story
author: Author
version: 1.0
description: A short romance told through a phone.
language: en
cover: assets/cover.png
wallpaper: assets/wallpaper.png
status: ongoing
asset_style: 2d
content_tags: sfw, romance, slice_of_life, vacation


@characters
player:
	name: Me
maya:
	name: Maya
	avatar: assets/maya.png


@chapter arrival
name: Arrival
subtitle: A new beginning

=== maya_arrival ===
participants: player, maya
start_when: all
---
maya: Did you arrive safely?

--> Yes, I just arrived:
	maya: Perfect. Welcome!
--> Not yet:
	maya: Tell me when you get here.

<<end_chapter
	key: promising_encounter
	title: A promising encounter
	summary: Maya's invitation changes the course of your vacation.
	next: tomorrow
>>
===

@chapter tomorrow
name: Tomorrow

Story metadata

The story metadata starts after @story and ends at @characters. id identifies the save/package while story is the displayed title.

FieldRequiredMeaning
formatYesCanonical SemVer identifier subtext-0.4.0; older revisions with the same first number and legacy subtext-2 remain readable and normalize on save.
storyYesDisplayed title.
idYesUnique package identifier.
authorNoDisplayed author.
versionNoImmutable MAJOR.MINOR Story revision managed by Studio's Publish flow. Starts at 1.0; non-structural updates increment MINOR, while structure or routing changes increment MAJOR and reset MINOR.
save_reset_versionNoMAJOR release boundary created by Studio for a breaking update. Saves from older Story revisions are discarded instead of migrated. Later saves at or beyond this version are preserved.
descriptionNoStory description.
languageNoLanguage code such as en.
coverNoPackage-relative cover image. Studio stores imported covers as assets/cover.png.
wallpaperNoPackage-relative phone wallpaper. The Mountains wallpaper is used when omitted.
bank_enabledNoEnables the Bank app, transfer blocks, and priced Nozamart purchases. Defaults to false.
bank_initial_balanceNoNon-negative balance used at the start of a fresh playthrough. Defaults to 0.
currencyNoCurrency symbol used for display. Defaults to $; the current Studio banking controls author dollar amounts.
statusNoCompletion status: ongoing (the default), on_hold, abandoned, or completed. Completed stories receive an automatic completed system tag.
asset_styleNoControlled visual-style tag: 2d, 3d, or pixelart.
content_tagsNoComma-separated controlled content, genre, and kink tags selected in Studio. sfw and nsfw are mutually exclusive in the editor.
tagsNoLegacy free-form discovery tags. Still supported for compatibility; new stories should use the structured fields above.

The controlled content_tags recognized by the current UI and discovery system are:

  • rating: sfw, nsfw;
  • tone and genre: romance, comedy, drama, mystery, thriller, horror, slice_of_life, fantasy, sci_fi, paranormal, dark;
  • intensity and mood: vanilla, flirting, teasing, sensual, explicit, gentle, rough, romantic, taboo;
  • dynamics: consensual, dominance, submission, switch, bdsm, bondage, discipline, praise, degradation, humiliation, pet_play, master_servant, power_exchange, chastity, impact_play, sensory_deprivation;
  • situations: roleplay, cosplay, voyeurism, exhibitionism, public, office, vacation, strangers, friends_to_lovers, enemies_to_lovers, size_difference, cheating, ntr, cuckold, step_family, blackmail;
  • acts: nudity, masturbation, oral, vaginal, anal, toys, fingering, handjob, footjob, facesitting, rimming, double_penetration, fisting, wax_play, group, threesome, orgy, edging, denial, orgasm_control, multiple_orgasms, creampie, breeding, impregnation, watersports;
  • characters and fantasy: solo, male_female, female_female, male_male, bisexual, trans, futanari, monster, furry, inflation.

The source parser preserves unknown comma-separated values for forward compatibility, but the current structured tag system ignores them. Studio prevents selecting both ratings. If hand-authored source contains both sfw and nsfw, the discovery system discards sfw and treats the Story as NSFW.

Story length is not authored in the source. The runtime counts message-like blocks across every conversation, choice, and condition branch and assigns one of these system tags:

System tagAuthored message count
shortFewer than 100
medium100–299
long300–699
very_long700 or more

The canonical asset styles, content tags, labels, and length thresholds live in src/features/stories/runtime/story_tag_system.gd. Sexual tags describe adult characters and adult scenarios only. Fantasy or roleplay labels do not change that requirement.

Technical availability settings are intentionally not stored in the story text. Original stories use Godot OriginalStorySettings resources in res://stories/originals. Original packages are always included in exports; the resource exposes these options:

SettingEffect
display_orderOrders original stories in menus and Studio. Lower values appear first.
playable_in_gameShows the story in the player-facing story selection.
can_duplicateLets a player create an independent editable Project copy from Studio.

Players can never edit an original, including the tutorial. A duplicable original appears in Studio as read-only with a Duplicate action; the resulting Project receives its own story id and assets under user://story_projects/. Password-unlocked developer mode is the only mode that edits originals directly, and only while running the source project from Godot. These values belong to the .tres resource and must not be added to story.phone.

Story progress migrations

A package may place migrations.phone beside story.phone when a published update renames or remaps persisted story state. The file is optional: when it is absent, the migration set is empty. It uses the same canonical format identifier as the story, a dedicated scope, and the story's stable id:

After the first publication, Studio's Update compatibility action opens this file in its text dialog. If no file exists, Studio preloads a template for the next MAJOR boundary and refuses to save syntax errors or a mismatched story id. All v1.x revisions therefore target 1 -> 2; MINOR updates never use migration numbers of their own.

format: subtext-0.4.0
scope: migrations
story: community.a_new_story

@migration 1 -> 2
rename_variable: met_maya -> met_maya_at_beach
set_default: introduced = false

Every @migration n -> n+1 header advances exactly one positive integer Story MAJOR. A structural MAJOR update with no persisted-state change needs no section: restarting the active incomplete chapter from its checkpoint is sufficient. Renaming or removing persisted identifiers does require the matching MAJOR step. A present file must contain at least one migration section, and its story value must match the package's story.phone id. Do not skip ahead: a v1.x release may prepare 1 -> 2, but not 2 -> 3.

The supported operations are:

OperationSyntax and effect
rename_variableold -> new; renames a story value and its pending restart reference.
set_defaultvariable = value; sets a value only when that variable is absent.
map_variablevariable | old_value -> new_value; replaces one exact stored value.
rename_global_milestoneold -> new; renames a story-wide milestone.
rename_endingold -> new; renames the reached ending and chapter-completion references.
rename_chapterold -> new; renames current, pending, started, completed, and milestone chapter references.
rename_contentcollection | old_id -> new_id; renames content and its dependent progress markers when applicable. Collections are gallery, social_posts, shop_items, and locations.
set_chapterchapter_id; moves progress to that chapter, reopens the story, and clears a pending chapter ending.

Values are parsed as JSON literals when possible and otherwise as plain strings. The grammar is wholly declarative: unknown operations are rejected and the file cannot execute code. Migration is transactional across the current progress and its chapter checkpoint baseline; any invalid file, operation, or detected identifier collision rejects the complete migration without mutating the original save.

Published migrations are immutable. Once a frozen revision contains a migration step, later revisions must retain its operations unchanged. A locally prepared step may be removed while no frozen revision contains it. After publishing the target MAJOR, append only the step for the following MAJOR boundary.

Characters and script keys

Every character has two distinct names:

  • the script key, used by the story source and never displayed to the player;
  • the display name, written under name: and shown in the phone UI.

The author chooses the script key. Prefer a short, meaningful value such as ai, aichan, maya or dr_smith; generated names such as perso_1 are unnecessary.

@characters
player:
	name: Me
ai:
	name: Ai-chan
	avatar: assets/ai.png

The key is then used everywhere the character is referenced:

=== ai_intro ===
participants: player, ai
---
ai: Hi! I'm Ai-chan.

<<photo from:ai file:assets/selfie.png>>

<<unlock_shop
	key: charger
	name: Battery Charger
	recipient: ai
>>

<<wait
	condition: gift_sent charger ai
>>
===

Exactly one character must use the reserved key player. Studio keeps that key locked. For every other character, edit Script key on the character card. When a key changes, Studio updates:

  • conversation participants;
  • dialogue senders and join/leave characters;
  • photo, video, audio and social-post senders;
  • gift recipients;
  • start and wait conditions;
  • references inside choice and condition branches.

Existing asset paths are left unchanged when a key is renamed, because filenames do not have to match character keys. Newly imported avatars use the current key as their filename.

Character properties are intentionally small:

PropertyRequiredMeaning
nameYesPlayer-facing display name.
avatarNoPackage-relative avatar image.
descriptionNoText shown when this character is expanded in Contacts. Ignored for player.
show_affectionNoShows this character's score and progress bar in Contacts. Defaults to false.
affectionNoGlobal numeric story-value key used for this character's score. Required by <<affection>>.
affection_labelNoPlayer-facing score label. Defaults to Affection.
affection_initialNoStarting score. Defaults to 0.
affection_maxNoPositive display maximum for the Contacts progress bar. Defaults to 100.

The player avatar uses the same avatar property as every other character. Contact-only fields are not shown for player.

Every non-player story character appears as an expandable Contacts entry with their avatar and optional description. show_affection controls only whether the score and progress bar are visible. A character without a visible score still appears in Contacts. Tapping a character avatar in Messages opens that Contacts entry; Back returns to the conversation that opened it.

Studio's Increase contact score block serializes as:

<<affection character:maya value:1>>

The character must have an affection key. Positive and negative values add or remove points. During normal playback the change displays a short heart popup with a signed amount such as +1 or -2.

Skills

An optional @skills section follows @characters and precedes @preload or the first chapter:

@skills
confidence:
	name: Confidence
	icon: star
	initial: 0
	min: -10
	max: 10
PropertyRequiredMeaning
nameYesPlayer-facing name shown in the Journal and score requirements.
iconNoBuilt-in icon key: star, stars, brain, build, gym, heart, or speaking. Defaults to star.
initialNoStarting numeric value. Defaults to 0.
minNoMinimum value. Defaults to 0.
maxNoMaximum value. When omitted, the score is unlimited and the Journal shows its value without a progress bar.

The skill identifier, such as confidence, is a global numeric story-value key. Renaming it in Studio updates skill blocks, start and Wait conditions, condition blocks, and choice requirements. Change the value with <<skill skill:confidence value:1>>; negative values remove points. During normal playback, score changes display a short popup using the skill icon and a signed amount such as +1. Changes are clamped to the configured minimum and optional maximum.

The dedicated block accepts positive or negative changes:

<<skill skill:confidence value:-2>>

operation:add on a generic Set value block can also increment any numeric story value, including a skill or affection key:

<<set variable:confidence value:1 operation:add>>

When a choice has a skill or affection condition, its icon and compact threshold remain visible whether the choice is available or locked. A locked choice remains visible but disabled. The same skill or affection value can be selected in a start or Wait condition.

Banking

Banking is disabled by default. Enable it in Studio, or define it in story metadata:

bank_enabled: true
bank_initial_balance: 100
currency: $

When banking is enabled, Nozamart items can have a price. Incoming transfers happen automatically and appear as a Bank card in the conversation. Outgoing transfers ask the player to confirm the payment. Both are recorded in the Bank app. The Bank app itself is absent from the phone when banking is disabled.

<<bank_transfer
	character: maya
	direction: pay
	value: 15
	label: Wero for dinner
>>

Use direction:receive for an automatic incoming payment. Use direction:pay for a player payment button. The button is disabled when the current balance is too low, and the story remains paused until the player can pay. Transfer amounts must be positive; the direction determines whether the amount is credited or debited.

PropertyRequiredMeaning
characterYesNon-player character sending or receiving the money.
directionYesreceive credits the player automatically; pay asks the player to confirm a debit.
valueYesPositive transfer amount.
labelNoDescription stored with the Bank transaction and shown on its conversation card. Defaults to Money transfer.

The current balance is available to start conditions, Waits, condition blocks, and choice requirements as bank_balance.

Preloaded content

An optional @preload section goes after @characters and the optional @skills section, and before the first chapter. It defines the phone content that already exists when a new playthrough begins:

@preload
conversation: amy_intro

<<photo
	from: amy
	file: assets/amy_profile.webp
	asset_name: Amy profile
	caption: From last summer
>>

<<social_post
	key: amy_first_post
	from: amy
	text: First post of the summer.
	media: photo
	file: assets/summer.webp
>>

<<unlock_shop
	key: flowers
	name: Flowers
	description: A small bouquet.
	file: assets/flowers.webp
	recipient: amy
>>

<<set variable:introduced value:true>>

conversation: may be repeated. It loads the referenced conversation's linear messages and media into already-read history before the first chapter separator, even when its normal start_condition values are not yet satisfied. Interactive choices, unresolved waits, branches, and chapter endings remain live rather than being resolved during preload. Conversation photos and videos marked for Gallery are available there too. Preloaded Gallery media, OverFaunt posts, Nozamart items, and GPS locations do not create a new-content badge.

In Studio, preloaded conversations are created directly from the Preloaded content screen. Existing chapter conversations are not offered for inclusion there. The corresponding conversation: id line is generated automatically when the story is saved as text.

The section accepts:

EntryPreloaded result
conversation: idInserts its linear content as already-read conversation history.
<<photo ...>>Adds a photo directly to Gallery; gallery: true is implied.
<<video ...>>Adds a video directly to Gallery; gallery: true is implied.
<<social_post ...>>Adds an OverFaunt post.
<<unlock_shop ...>>Adds a Nozamart item.
<<unlock_location ...>>Adds a GPS location.
<<set ...>>Sets an initial global story value.

Preloads run once per fresh playthrough. A complete story restart applies them again. A chapter restart restores the checkpoint that already contains them. Media files referenced here are included in a .subtext release even when no conversation references those files.

Chapters

@chapter introduction
name: Welcome to Subtext
subtitle: Your first night with Ai-chan

A chapter can remain in the editable story while being excluded from the playable release:

@chapter upcoming
name: Coming soon
included_in_game: false

included_in_game defaults to true. Studio labels excluded chapters Draft. New chapters and duplicated chapters start as drafts, while the initial chapter must always remain included. Playing the story normally hides draft chapters and their conversations; developer mode can still access them. An excluded, non-initial Draft chapter may contain no conversations at all. A Project containing only one excluded chapter is invalid because that chapter is necessarily the initial chapter. When Studio exports a .subtext package, draft chapter text and assets referenced only by draft chapters are omitted. A published chapter that normally leads to the next draft chapter simply completes the currently distributed story until that chapter is published.

The first @chapter is the initial chapter automatically. A chapter export starts with:

format: subtext-0.4.0
scope: chapter

The chapter declaration establishes the current chapter. Every slice that follows belongs to it until another @chapter appears.

A chapter export contains that declaration followed by all of its time slices and conversations. Loading it from a chapter screen replaces the complete chapter timeline while keeping the current chapter id stable.

Time slices

@slice opening
style: none

=== maya_intro ===
participants: player, maya
---
maya: Hi!
===

@slice next_morning
label: The next morning
style: big
start_when: all
start_condition: conversation_finished maya_intro

A slice is a chronological separator at the same level as a conversation. Conversations inherit the closest preceding @slice. The first slice starts with the chapter and uses style: none; later slices use small or big.

HeaderMeaning
@slice idStable slice id and section opening.
labelFree text displayed during the transition, such as One hour later, Later, or Three months later.
stylenone for the chapter opening, otherwise small or big.
start_whenall or any for the trigger conditions.
start_conditionRepeatable condition that triggers the slice.

Only one slice is active. Conversations in later slices cannot be revealed or played until every preceding slice has activated. A conversation with no start condition becomes available when its own slice activates. When a later slice has no explicit start condition, it becomes ready after every conversation in the active slice has finished; explicit All/Any conditions override this default. Once the next slice is ready, it becomes pending and playback stops at that boundary. Returning to the phone home screen plays the visual transition. Only after the animation finishes does the slice activate; its conversations can then unlock, stage their first incoming message, and send their notification.

The slice label is recorded for every conversation thread but is displayed only when that thread later receives normal message or media content. A trailing label therefore stays invisible. If several slices pass before the next content entry, only the newest label is displayed immediately before that entry. System and chapter context entries remain silent and do not change this behaviour. Older threads remain readable but no longer advance.

Conversations

@chapter introduction
name: Welcome to Subtext

@slice opening
style: none

=== maya_chat ===
participants: player, maya
avatar: assets/group.png
start_when: all
start_condition: variable met_maya truthy true
---
maya: Hi!
===
HeaderMeaning
=== id ===Stable conversation id and section opening.
nameOptional displayed group-thread name. Direct threads infer the other character's name.
studio_labelOptional author-only label used to distinguish conversations in Studio. It is omitted from playable data and never changes the phone title.
threadOptional stable thread key. Use the same value on separate conversation blocks that must share history despite different initial members.
participantsComma-separated character ids; must include player.
avatarOptional group-thread avatar.
start_whenall or any for the start conditions.
start_conditionRepeatable condition that unlocks the conversation.
startOptional non-default first block id. The first body line is used when omitted.

A conversation's three naming/identity fields serve different purposes:

  • name is the conversation name shown to the player in the phone UI. A direct conversation can omit it and infer the other character's display name;
  • studio_label is an editorial label shown only in Studio, useful for distinguishing similarly named conversations. It is never shown to the player;
  • thread is the stable history identity. Separate conversation sections with the same thread continue the same message history; it is not a displayed label.

This complete conversation example also shows the source equivalent of Studio's Show a card in the conversation option for a Nozamart item:

=== maya_arrival_gift ===
name: Maya
studio_label: Chapter 1 · arrival gift
thread: maya
participants: player, maya
---
maya: I left a welcome gift in Nozamart for you.

<<unlock_shop
	key: welcome_flowers
	name: Welcome Flowers
	description: A bouquet for the first night.
	file: assets/welcome_flowers.webp
	recipient: maya
	in_conversation: true
>>
===

Here, in_conversation: true makes the unlocked item appear as a visible card in the message timeline. Without it, the item is unlocked in Nozamart without inserting that card. show_card is not a supported property and causes an import error; generators must emit in_conversation: true instead.

A conversation inside a complete story inherits the closest preceding @chapter and @slice. A standalone conversation export starts with format: subtext-0.4.0 and scope: conversation; that fragment includes both chapter: and slice: because it has no surrounding timeline context. Loading it from a conversation screen replaces that conversation while keeping its current identity and timeline placement stable.

start_condition can be repeated. start_when: all requires every listed condition, while start_when: any requires at least one. With no start conditions, the conversation becomes available as soon as its slice activates. Prefer all when there is only one condition; reserve any for genuine alternatives.

Dialogue and optional ids

maya: Did you arrive safely?
player: Just got here.

The text after the first colon is dialogue. It does not require quotes. Ordinary messages and actions do not need an id: the importer creates their internal ids automatically and Studio leaves them out of exported text.

Write \n inside dialogue when one message needs an explicit line break.

During play, a player can resume from an earlier message by right-clicking it on desktop or pressing and holding it on mobile. After confirmation, the story restores its state at that point, replaces later progress on that timeline, and continues just after the selected message. Authors do not need to add a special command or message id for this behavior.

Add #id: only when another instruction needs to target that particular block. For example, this return loops back to a named message:

maya: Are you absolutely sure? #id:ask_again

--> Ask me again:
	<<return ask_again>>
--> Continue:
	maya: All right. Let's go.

The tag is not displayed to the player. Studio also retains or emits an id automatically when a non-sequential start, jump, return or milestone condition references it. An id entered without any reference is preserved, allowing an author to name the target before writing the reference.

Use <<join character:name>> and <<leave character:name>> to change the current conversation's membership. The change is saved, the group header is refreshed, and a system message is displayed. It never creates a new message thread:

<<join character:zoe>>
zoe: Hey everyone!
<<leave character:zoe>>

The character must exist under @characters, but does not need to be listed among the conversation's initial participants.

If the story continues that same thread in a later conversation block whose initial members differ, give both blocks the same explicit thread value:

=== maya_private ===
thread: movie_friends
participants: player, maya
---
<<join character:zoe>>
...
===

=== movie_friends_later ===
thread: movie_friends
participants: player, maya, zoe
---
...
===

Without thread, separate blocks use the default inference and are grouped only when their initial participant lists match. This avoids accidentally merging a private chat with a group after somebody leaves.

Choices

Consecutive --> lines at the same indentation level form one choice. The colon marks the beginning of that option's indented content. No choice id is needed unless the choice itself becomes a reference target.

--> Ass:
	maya: An intellectual. I see.
	<<set variable:preference value:ass>>
--> Boobs:
	maya: Classic. I respect it.
	<<set variable:preference value:boobs>>

maya: Moving on...

After either branch, execution resumes at the next unindented block. Use an explicit return only when a branch reconnects somewhere else, as shown in the optional-id example above.

--> Tell the truth:
	maya: Thank you for being honest.
--> Lie:
	maya: I don't believe you.

maya: We should keep moving.

Choices require two to four options and can be nested.

An option can require a story value to match:

--> No:
	maya: Wrong answer.
--> Nope:
	maya: Still wrong.
--> <<if restarted>> Yes:
	maya: Much better.

All authored options remain visible. An unmet option is disabled, visually faded, and displays its requirement. It accepts the same operators as a regular condition, for example --> <<if score at_least 3>> Continue:. If every answer is locked, the choice remains on screen and the story waits until another action or rewind changes the required value.

Conditions and story values

Set a safe scalar story value with:

<<set variable:preference value:ass>>
<<set variable:score value:3>>
<<set variable:ready value:true>>

No expression or external code is executed.

Conditions use Yarn-style branches:

<<if preference equals ass>>
	maya: Good choice.
<<else>>
	maya: A classic.
<<endif>>

Supported operators are truthy, falsy, equals, not_equals, greater, at_least, less and at_most.

Media

<<photo
	from: maya
	file: assets/beach.webp
	rating: nsfw
	gallery: true
	tagged: maya, zoe
	asset_name: Sunset at the beach
	caption: Wish you were here
>>

<<video>> and <<audio>> use the same media fields. Photos and videos accept gallery and tagged; videos also accept loop.

PropertyPhotoVideo/audioMeaning
fromYesYesSender's character key.
fileNoYesPackage-relative asset path. A photo may deliberately leave it empty to use the named missing-image placeholder; video and audio require a file.
captionNoNoText displayed with the media.
asset_nameNoNoReadable asset label used by Studio and missing-media placeholders.
ratingNoNoMedia content rating: sfw or nsfw. Defaults to sfw; Studio exposes this as an NSFW checkbox.
loopVideo onlyWhen true, restarts the video automatically after it reaches the end. Defaults to false.
galleryNoVideo onlyWhen true, unlocks the photo or video in Gallery.
taggedNoVideo onlyComma-separated character keys used by Gallery filters. Defaults to from; an empty value explicitly tags nobody.

Gallery filtering uses tagged, not the sender. This allows media sent by one character to appear under every character visible in it—or under none. Studio selects the sender initially and lets the author toggle every character independently.

Studio accepts PNG, JPG, JPEG, WebP and animated GIF for photos; OGV and, on desktop, MP4 for video; and OGG, WAV and MP3 for audio. MP4 imports are converted to OGV before they are stored, so story source always references an OGV asset. Imported files are copied into the story's assets folder. The clipboard format references them by name but never embeds their binary contents.

If a photo has no available file, Messages, Gallery and OverFaunt display a black square with asset_name in white. When asset_name is empty, the filename is used, followed by Missing image as a final fallback. This makes unfinished visual assets visible instead of silently collapsing them.

Moments

A Moment is a closed visual timeline embedded in a conversation. It is intended for scenes in which the media, dialogue, and pacing should be experienced as one uninterrupted sequence. By default it is a Conversation Moment; add live:true to make it a Live Moment.

The first nested block must be a photo or video. It creates the conversation invitation and also becomes the first visual. In both modes, the invitation places a white tap icon above Open Moment on a black-to-transparent gradient. Story playback pauses there until the player taps the media. After a completed Moment, tapping the same invitation replays it without adding duplicate history entries.

The two modes deliberately use different presentations after that shared invitation:

  • A Conversation Moment keeps the real Messages interface visible at the bottom. Its photo or video is opaque across the upper screen, then fades smoothly to full transparency toward the lower screen to reveal the conversation and composer underneath.
  • Conversation dialogue uses the standard SMS system: incoming and outgoing bubbles, typing indicators, the normal player-message composer, and the normal choice controls. New bubbles enter at the bottom and push earlier bubbles upward behind the increasingly opaque media, so they disappear progressively without a separate Moment transcript layer.
  • A Live Moment removes the phone interface and uses the media as a full-screen background. It shows one visual-novel-style line at the bottom at a time. Each line waits for a tap unless Auto is enabled at the top; choices always require an explicit selection.
  • The top-right hide/show control belongs to the Live presentation and temporarily removes its dialogue and pending action to reveal the full background.
  • In both modes, each photo or video replaces the current visual, non-player dialogue follows the configured pacing, and the final visual remains until the presentation fades out.

Conversation Moment messages, selected answers, and later nested media are delivered through the normal conversation history as they play; the opening medium is already represented by the Open Moment invitation. A Live Moment leaves its dialogue, choices, and nested media out of Messages. In both modes, the invitation remains in the conversation and can be tapped again to replay the sequence, replay does not add duplicate history entries, and Gallery unlocks still apply normally.

<<moment>> #id:private_reward
	<<photo
		from: maya
		file: assets/window.webp
		gallery: true
		dynamic: true
	>>
	maya: Stay with me.
	player: I'm not looking away.
	<<delay seconds:1.0>>
	<<cum seconds:5>>
	<<video
		from: maya
		file: assets/curtains.ogv
		loop: true
	>>
	maya: This is just between us.
<<endmoment>>

Use <<moment live:true>> for the live variant. Omitting live keeps the existing Conversation Moment behaviour.

Use a stable #id for every Moment. The runtime uses it to track the invitation, interrupted playback, history insertion, and replay.

Moment blocks

Moment timelines are ordered and accept the following nested blocks:

BlockBehaviour inside a Moment
MessageConversation: delivers a standard SMS bubble; player uses the normal send interaction. Live: displays one VN line and waits for a tap unless Auto is enabled.
PhotoReplaces the current visual. Conversation media fades transparent toward the phone UI; Live media fills the screen. Add dynamic: true for a subtle looping pan-and-zoom motion.
VideoReplaces the current visual and starts playing immediately. Add loop: true to repeat it until another visual replaces it or the Moment ends.
DelayHolds the current screen for the authored number of seconds.
Fake typingConversation: displays the normal SMS typing indicator. Live: adds a silent pacing pause.
ChoicePauses for two to four player answers using the current mode's controls. The selected answer can run its own Moment branch before returning to the timeline.
Cum effectPlays a small white flash followed by a full flash, keeps the built-in cum texture visible for its duration, then fades it out.

Photo and video blocks use the normal media properties, but captions are not drawn over the visual itself; add a Message block when text must appear in the Live presentation. A Moment photo with gallery: true unlocks when playback reaches that photo. dynamic applies only to photos used inside a Moment, and Studio exposes it as Dynamic pan and zoom. Studio exposes video looping as Loop video.

Videos do not pause the timeline while they play. Add Delay blocks when dialogue or a background change must line up with the footage. The Cum effect remains visible across later background changes for its configured duration (5 seconds by default), then fades out over 1 second.

Choices can be nested inside a Moment. Their answer branches may contain the same blocks supported by the Moment timeline, and then return to the next Moment block by default. Conditions, audio, story-state actions, and chapter actions cannot be nested inside a Moment.

<<endmoment>> is the required structural delimiter in the text format. Studio generates it automatically and does not expose it as a playable block.

Timing

<<fake_typing character:maya seconds:2.5>>

Time progression is authored exclusively with chapter-level slices, not conversation blocks.

<<fake_typing character:name>> temporarily displays Character is typing ..., then removes it without sending or saving a message. A short empty beat follows its disappearance before the next block begins, so a following real typing indicator does not visually blend into it. Normal incoming messages use the same indicator automatically. The duration defaults to 1.5 seconds and previously seen typing, including the empty beat, is accelerated by Skip.

Slice labels are narrative text only: Subtext has no separate story clock, and the phone and lock-screen clock continue to use the player's local system time. fake_typing is visual and does not add a saved chat message.

System messages

Use a system message for centered contextual text without playing a slice transition:

<<system_message text:"You feel watched.">>

It is added only to the current conversation by default. Set broadcast: true to copy the same message to every conversation the player has already discovered:

<<system_message
	text: The building is now closed.
	broadcast: true
>>

Broadcasting is silent. It does not reveal hidden conversations, alter conversation ordering, create unread dots, or display notifications. Group-thread aliases receive the entry only once. A system message does not trigger a slice transition or pause the current conversation.

System notices

Use a system notice for a full-screen message that sits outside the fiction:

<<system_notice
	label: "A NOTE FROM THE AUTHOR"
	heading: "More chapters are coming"
	text: "This story will continue in a future update."
	button: "Continue"
>>

The card pauses the current conversation until the player presses its button, then continues with the next block. label, text, and button may be customized; heading is required. Unlike a System message, a System notice is not written into chat history and cannot be broadcast.

Waits and conditions

<<wait
	mode: all
	condition: location_visited beach
	condition: variable ready truthy true
>>

Supported wait and start conditions:

SyntaxMeaning
milestone chapter <chapter_id> <milestone>This chapter reached a named milestone.
milestone global <milestone>The story reached a milestone shared by all chapters.
conversation_finished conversationA conversation in the current chapter ended.
location_visited locationThe player opened a location.
gift_sent item recipientA gift was sent.
post_viewed postA social post was viewed.
variable name operator valueA story value matches.

Story values include normal values created by <<set>>, skill identifiers, character affection keys, and bank_balance while banking is enabled. All of them support the same comparison operators.

In milestone chapter <chapter_id> <milestone>, the first chapter selects chapter-local scope; <chapter_id> is a placeholder for the exact id declared by the owning @chapter, not a second literal chapter keyword. A chapter-local milestone condition must use the id of the chapter containing the slice, conversation, or Wait that owns the condition. For example, inside @chapter chapter_3:

<<milestone name:j3_velvet_approved scope:chapter>>
start_condition: milestone chapter chapter_3 j3_velvet_approved

Use scope:global with milestone global j3_velvet_approved only when later chapters must observe the same milestone. Story values are global independently and use variable name operator value instead.

start_condition controls when its conversation becomes available. <<wait>> never opens another conversation; it only pauses the sequence that contains it until its conditions are satisfied.

Use these rules when sequencing conversations:

IntentAuthoring rule
Open B after A naturally endsGive B start_condition: conversation_finished A.
Open B while A is still runningSet a milestone in A and use it as B's start_condition.
Resume A only after B endsAfter that milestone, put a <<wait>> in A with condition: conversation_finished B.

The second and third rules are commonly paired for an intentional detour: A makes B available, pauses, then resumes after the player finishes B. Opening remains owned by B; pausing remains owned by A.

When a satisfied <<wait>> leads directly to <<end_chapter>>, the runtime prepares the chapter ending automatically. Returning from GPS, OverFaunt or Nozamart to the phone home screen therefore displays the ending transition without requiring the player to reopen the conversation.

Record progress without pausing:

<<milestone name:ready_to_leave scope:chapter>>

A milestone is one named boolean value, not a conversation/block reference. scope:chapter keeps it local to the chapter that records it, so another chapter may reuse the same name. scope:global shares the value across the whole story and its name must be unique across all chapters. Wait and start conditions pick from these explicit definitions. Name milestones as states such as report_available, not commands such as open_report. The old milestone conversation block_id condition syntax remains readable for compatibility with existing stories, but Studio no longer creates it.

Phone actions

Conversations are revealed declaratively through their own start_condition entries. A milestone block can set the chapter-local or global value used by that condition. Story source has no separate conversation-unlock command.

Create a social post:

<<social_post
	from: maya
	text: Best afternoon ever.
	media: photo
	file: assets/post.webp
	rating: sfw
	gallery: true
	tagged: maya, zoe
	likes: 42
	reposts: 3
	notification: false
	key: maya_beach_post
	asset_name: Beach post
>>

When file is present, the post is treated as a photo even if an older source says media: none. New stories should still write media: photo explicitly. A missing file uses the named black-square placeholder described in the Media section.

social_post properties:

PropertyRequiredMeaning
keyRecommendedStable post id used by post_viewed.
fromYesCharacter profile publishing the post.
textNoPost body.
mediaNonone, photo, or video.
fileNoImage or OGV asset.
likes / repostsNoAuthored display counters.
asset_nameNoReadable media label and missing-image fallback.
ratingNoMedia content rating: sfw or nsfw. Defaults to sfw.
galleryNoFor a photo or video post, also adds its media to Gallery.
notificationNoShows a top-of-phone banner when the post is published. Defaults to true.
taggedNoCharacters visible in the image, used by Gallery filters. Defaults to from and may be empty.

Only media: photo and media: video posts can be added to Gallery. A photo file may remain empty while being authored; the named missing-image placeholder is used. OverFaunt itself continues to filter posts by the publishing profile, while the Gallery copy is filtered using tagged.

Add a new Messages contact:

<<unlock_contact
	conversation: maya_first_text
	label: New contact added
>>

The target must be another conversation in the same chapter. A conversation referenced by an unlock_contact block stays hidden until that block runs, regardless of its normal Start when conditions. The player receives a New contact notification, but the contact is marked read and creates no unread dot. Give the target conversation a player message or choice first when the player should initiate it.

Unlock a Nozamart gift:

<<unlock_shop
	key: flowers
	name: Flowers
	description: A small bouquet.
	file: assets/flowers.webp
	asset_name: Flower bouquet
	rating: sfw
	recipient: maya
	in_conversation: true
	price: 20
>>

Studio's Show a card in the conversation checkbox serializes exactly as in_conversation: true. The default is false. The similarly named show_card property does not exist and is rejected during import.

When banking is enabled, price is the non-negative amount debited when the player sends the item. An unaffordable item cannot be sent. Successful purchases appear in Bank transactions. When banking is disabled, prices do not affect gifting and the Bank app remains hidden.

Unlock a GPS location:

<<unlock_location
	key: beach
	name: Moonlight Beach
	description: A quiet beach outside town.
	file: assets/beach.webp
	asset_name: Moonlight beach
	rating: nsfw
>>

Keys connect phone activity back to conditions: shop key is used by gift_sent, location key by location_visited, and post key by post_viewed. A shop item may also set a recipient.

Image assets on Nozamart items and GPS locations also accept the optional rating property.

The Studio-wide Media & events library currently indexes conversation media, OverFaunt posts and Nozamart items. GPS actions are edited from their source conversation.

Endings

A conversation ends naturally when its main sequence has no next block. No explicit ending block is needed.

End a chapter and optionally unlock the next one:

<<end_chapter
	key: promising_beginning
	title: A promising beginning
	summary: Maya's invitation changes everything.
	next: tomorrow
>>

To turn an ending into a retry, restore the chapter and apply a value after the restoration:

<<end_chapter
	key: wrong_answer
	title: Wrong answer
	restart: chapter
	on_restart: restarted = true
>>

An ending with restart: chapter uses the distinct red BAD END window and displays Restart chapter instead of the normal golden completion card. Pressing it restores the chapter-entry checkpoint just like rewinding from a message: conversation history, choices, story values, milestones, endings, replay state, and unlocks produced afterward are reset. Already-seen block memory remains recorded so replayed content can still be recognized. on_restart is then applied, so it can reveal a conditional option such as --> <<if restarted>> Yes: on the next attempt. Unconditional options remain visible after the restart.

A restart ending cannot also use next:. The current implementation supports restart: chapter.

Chapter transition system messages—chapter completion, the next chapter opening, and chapter restart—are appended to every conversation the player has already discovered. They are contextual separators rather than incoming messages: they do not reveal undiscovered conversations, change thread ordering, create unread dots, or display phone notifications.

PropertyRequiredMeaning
keyYesStable ending id recorded in progress.
titleYesEnding title displayed to the player.
summaryNoAdditional ending text.
nextNoChapter unlocked after a normal ending.
restartNochapter enables the retry ending.
on_restartNoScalar assignment applied after checkpoint restoration.

Validation

Studio and the runtime reject a story when:

  • the format is malformed or is neither supported subtext-0.4.0 nor the readable legacy alias subtext-2;
  • the story has no title or id;
  • player is missing;
  • the story has no chapter;
  • the initial chapter is excluded from the game;
  • a conversation references a missing chapter or character;
  • a preload references a missing conversation, character, or recipient;
  • a preload contains a command that cannot be used as preloaded content;
  • a block, jump, return, condition, asset recipient or chapter target is invalid;
  • a conversation is empty;
  • a choice does not contain two to four answers;
  • a wait/start condition has an invalid type or target;
  • a choice or if condition has an invalid comparison operator;
  • an ending combines restart: chapter with next:;
  • indentation leaves a branch ambiguous.

The importer reports line numbers for syntax errors. Chapter and conversation fragments are first parsed on their own, merged into the current story and then validated as a complete story.

Studio and LLM workflow

Use Copy story text, Copy chapter text or Copy conversation text. Paste the result into a text editor or an LLM, edit it, then use Load from text at the same level.

When asking an LLM to edit a fragment, tell it:

Edit dialogue and narrative structure as requested. Preserve format, section markers, indentation and commands. Preserve existing #id: tags because they mark reference targets; do not add ids to ordinary messages or actions.

When asking an LLM to generate a complete editable archive instead of clipboard text, point it to Editable .subtext-project transfer bundle and require it to execute the six-step bundle-generation recipe. In particular, explicitly ask it to derive the manifest from the final Story id and release version, keep both required files at the ZIP root, include real media bytes rather than renamed or empty placeholders, and reopen the completed ZIP for verification.

Clipboard text contains source only. Media files remain in the story package and are not embedded in the text.

At story level, Load from text replaces the source structure. At chapter level it replaces that chapter and all its conversations while keeping the selected chapter id. At conversation level it keeps the selected conversation and chapter ids. Existing package media stays in place for all three scopes.

Command reference

CommandPurpose
<<join character:name>>Adds a member to the current thread.
<<leave character:name>>Removes a member without creating a new thread.
<<fake_typing character:name seconds:2.5>>Shows and removes a temporary typing indicator.
<<delay seconds:1.5>>Pauses story playback.
<<system_message text:"..." broadcast:true>>Adds centered contextual text; broadcast defaults to false.
<<system_notice heading:"..." text:"...">>Shows a dismissible out-of-story card, then resumes playback.
<<wait ... >>Pauses until phone or story conditions are satisfied.
<<set variable:name value:value>>Stores a safe scalar story value.
<<affection character:name value:1>>Adds or removes points from a character's configured Contacts score.
<<skill skill:name value:1>>Adds or removes points from a defined skill.
<<bank_transfer ... >>Automatically receives money or waits for the player to confirm an outgoing payment.
<<if ...>>Selects a conditional branch.
<<milestone name:value scope:chapter>>Sets a chapter-local or global named milestone.
<<chapter chapter:id>>Legacy direct chapter transition; prefer end_chapter.
<<photo ... >>, <<video ... >>, <<audio ... >>Sends media in the conversation.
<<social_post ... >>Unlocks an OverFaunt post.
<<unlock_contact ... >>Announces and reveals a new Messages contact.
<<unlock_shop ... >>Unlocks a Nozamart item.
<<unlock_location ... >>Unlocks a GPS location.
<<end_chapter ... >>Records an ending and completes or restarts a chapter.
<<jump id>>, <<return id>>, <<stop>>Overrides implicit flow when necessary.