VN-Mikro
Write a visual novel the way you would write a play. VN-Mikro turns your script, pictures and music into a finished game — with menus, saves, rollback and builds for every platform already done.
This handbook matches runtime 0.1.0.
Two ways in
Making a game? Start at What VN-Mikro is and read the sidebar top to bottom. Each part adds one layer to your game:
| Part | You learn to… |
|---|---|
| Welcome | What the player does for you, JSON and folders, author tools, first project |
| Writing your story | Scenes, speakers, stage directions, choices and pacing |
| Setting up your game | Name, text speed, sound, saves, Back, layout, multi-story packs |
| Directing a scene | Precise direction, live flags, sticky / clear |
| The stage | Planes and slots, backgrounds, props, music |
| Characters | Cast, layered packs, talking mouths, expression fades, places |
| Dialogue panels | Routing, presets, height/scroll, several panels, append, presence, art |
| Look and menus | Theme, name plate, choices/inputs, free choices, title/pause/HUD |
| Polish | Transitions, animation, shaders, cursor, notifications, keys, translations |
| Ship | Identity, platforms, Build, runtime version, native extensions |
Looking for something? Open Reference:
- File maps — see a whole file at once, every key clickable.
- Everything A–Z — one flat list of every command, key and action.
- How these docs work — guide vs map vs detail.
Worked projects live in the examples/ folder next to the player.
What VN-Mikro is
VN-Mikro is a player for visual novels. You give it a folder — your script, your pictures, your music — and it plays that folder as a finished game.
my-game/
game.json your game's name and settings
story/script.txt the story, written like a play
characters/ character pictures
backgrounds/ scene pictures
audio/ music and sound effects
There is no editor to learn and no project file to keep in sync. You write in any text editor, press one key, and the player shows the change.
What you write
Most of your time goes into script.txt. It reads like a script for the stage or screen:
[bg street fade 0.5]
[hana left]
HANA:
You came back.
> Of course I did. -> stay
> I'm just passing through. -> leave
Who speaks, what they say, where the scene goes next. The bracket lines are stage directions: show this place, bring in this character.
When a scene needs more — a precise camera move, a custom menu, a character with blinking eyes — you add a small settings file next to the script. The script stays readable.
What the player does
Everything a player expects from a visual novel is already there: text that types out, a name plate, choices, save and load, stepping back through lines, a history log, title and pause menus, a settings screen, and builds for desktop, web and phones.
You do not build these. You decide how they look and when they appear. The next page lists them: What VN-Mikro handles for you.
Who it is for
VN-Mikro is made for story-first games: kinetic novels, dialogue-heavy stories, branching romances, mysteries. If your plan is “write the scenes, then dress them”, you are in the right place.
It is not a general game engine. If your game is mostly a custom minigame with some dialogue around it, a full engine suits you better.
The spirit is closer to classic Japanese script engines (think KiriKiri / KAG) than to a full game IDE: plain script, data beside it, player runs the folder. Those tools went quiet; the habit of writing VNs as text didn’t.
Built on a proven engine
The player is built with the Godot engine, so it runs smoothly on Windows, macOS, Linux, the web and Android. You never open Godot yourself — you only work with your project folder.
Next
What VN-Mikro handles for you
Every visual novel needs the same hundred small things before a single player enjoys it. Most of them are invisible when they work and painful when they do not. VN-Mikro ships them ready, so your work is the story.
Below: what the player does on its own, and what you can change when you want to.
Reading the text
| Handled for you | You can change |
|---|---|
| Text types out letter by letter | Speed, or other ways to reveal text (soft fade, pop, rise) |
| Click finishes the line; next click moves on | — |
| Holding the advance key speeds text up | The fast speed |
| Pauses inside a line, then continue | Where the pauses go |
| A short voice beep while text types, with slight pitch changes so it never sounds robotic | The sound, pitch range and volume — or turn it off |
| Bold, italic and coloured words | — |
| Auto-advance after a line | Delay per line, or a default |
| Readers can change text speed, auto-advance and text size in Settings | Whether those rows appear |
Who is speaking
| Handled for you | You can change |
|---|---|
| The name plate shows the speaker’s name | Its look, position, or a custom name like “???” |
| Text appears in a dialogue panel | Extra panels, whisper looks, what quiet panels do — Dialogue panels |
| The speaker is highlighted; others step back | How strong the effect is |
| Mouths move while a character talks, eyes can blink | Which pictures move, and how fast |
Characters and scenes
| Handled for you | You can change |
|---|---|
| Characters get a place on stage (left, centre, right) without setup | Number of places, their size and position |
| Moving a character frees the old spot | — |
| Changing an expression blends smoothly | Blend style |
| Characters and backgrounds fade in and out | Fade, slide, timing |
| The picture scales to any window size | Base resolution |
Choices and input
| Handled for you | You can change |
|---|---|
| Choice menu with numbers and keyboard / mouse control | Look, numbers on or off, order shuffled |
| Choices that show or grey out depending on earlier decisions | The conditions |
| Timed choices | The timer |
| Asking the player to type a name | Look and default text |
Saving and going back
This is where most homemade visual novels break. VN-Mikro keeps it stable.
| Handled for you | You can change |
|---|---|
| Autosave, and Continue on the title screen | — |
| Save and load in slots | Number of slots (save_slots); you build the slot menu — polished slot UI still early — Save, Back and story end |
| Loading brings back the whole scene: background, characters, music, effects, looks | — |
| Back steps through earlier lines, and the scene rewinds too | On or off, how far back, whether text retypes |
| History log of everything said | Where it appears |
| Settings (volume, fullscreen, language…) are kept separately from saves | Default values on first launch |
| Old saves that no longer match the story are detected, not silently broken | The message shown |
| Save files cannot be casually edited in released builds | — |
| “Saved” and “Loaded” messages | Text and duration |
Menus and screens
| Handled for you | You can change |
|---|---|
| Title, pause and settings screens | Buttons, layout, pictures — or your own screens |
| Settings rows for music, sound, voice beep, text speed, auto-advance, text size, fullscreen, language | Which rows appear and how they look |
| A small in-game bar (pause button, etc.) | Its contents and position |
| Credits after the last line, then back to title | Which screen, and whether to return |
| Music on the title screen that fades in and out, and does not restart when the same track plays again | Track, volume, fade times |
| Button click and hover sounds | Sounds and volume |
| A custom mouse cursor that hides while the player reads | Pictures, when it hides |
| All keys can be remapped | Default keys |
More than one language
| Handled for you | You can change |
|---|---|
| Language switch in Settings | Which languages are offered |
| Text and character looks per language | The translations |
Checking your work
While you write, the author player helps you catch mistakes before players do:
| Handled for you | You can change |
|---|---|
| Reload the story with one key and stay where you are | — |
| Check the whole project: broken jumps, branches that can never happen, missing characters, backgrounds, sprites or screens | — |
| Jump to any scene, with the right flags set on the way | Which flags to set first |
| See and edit flags while playing | — |
Shipping the game
| Handled for you | You can change |
|---|---|
| Builds for Windows, macOS, Linux, web, Android (install file and Play Store) | Which platforms you build |
| Your icon converted to each platform’s format | The icon picture |
| Game name on the window, the app file and the build folder | The name and version |
| Start-up splash on desktop, web and Android | Logo, background, colours |
| Author tools and debug keys removed from player builds | — |
| Files your game does not use are left out | Turn it off if you need them |
What it does not do (yet)
A few things other tools offer are not in VN-Mikro today: skipping already-read text, gamepad controls, and picture thumbnails on save slots.
Multi-scripting (several story packs / scripts in one project) and the Save/Load menu polish (slots work; finished UI still early) — details: In development.
Next
In development
These are planned or only partly finished. Do not build your whole game around them yet.
Save and load menus
Saving, loading, autosave, and Continue already work. session.save_slots sets how many manual slots you get (1–99). You build Save / Load screens yourself in screens.json.
What is not ready: a polished, finished slot UI (layout, picture thumbnails, the overall Save/Load experience authors expect out of the box). Slot count and early screens are usable tooling; the menu finish may change.
Until that lands, keep Save/Load screens simple. Details while setting the count: Save, Back and story end.
Multi-scripting (several stories in one project)
One project can already list more than one story pack in game.json (stories: each pack has its own folder and script.txt). Saves know which pack you were in. Unlock flags can gate the next episode.
What is not ready: a polished story-select / campaign hub. Today you wire that menu yourself in screens.json. Labels, hub layout, and the overall flow may change.
Until that settles, prefer one story/ folder and one script for a shipped game. Treat multi-pack as early tooling, not a finished feature. Keys and hub wiring: Several stories in one project.
3D models (Blender → the stage)
The direction: use Blender models (GLTF) as characters, props, and eventually backgrounds — not only flat PNGs. Named animation clips play from the story (idle loops, one-shot gestures).
Parts of this already exist in experiments (characters and sprites can mount a model; clips can run and stop). What is not ready for handbook authors: a stable, documented way to export, place, and light models in a normal project folder; full background / room integration; and a finish that matches the 2D path for save, shaders, and interaction.
Until that lands, ship with PNG and layered 2D characters. Treat 3D as upcoming, not as something to depend on for a release.
Character idle (2D layered packs)
The direction: soft breathe / sway / bob on layered character packs so standing characters feel alive without a new expression every beat. Pack settings would choose an idle motion; talking and focus reactions stay separate.
This may already appear as early pack options in some builds. What is not ready: a calm, final set of motions, tuning that looks good at every resolution, and clear docs for when idle fights with expression swaps or talking mouths.
Prefer still poses and your own blink / mouth layers today. Idle motion is polish on top — not required to finish a story.
Stage locations (richer rooms)
The direction: backgrounds that behave more like places than single pictures — when the room resets between beats, layered background packs, clickable hotspots in the room, and a simple room camera (pan / focus) driven from the story.
Schema and tests for a stage-style flow are under work. What is not ready: author handbook pages, a finished Mode A spelling for everyday use, and a player-facing room editor. Clickable props today are still normal sprites / objects / screen hooks.
Keep using [bg …] and stage sprites for shipped games. Stage locations will extend that world later, not replace your current backgrounds overnight.
Video on the stage
The direction: play video as stage pictures — sprites / layers you place like any other prop — with simple controls from the story (play, pause, seek, loop, stop).
Nothing here is handbook-ready yet. Ship with still images and flipbooks (frames) for motion. Treat video layers as upcoming polish, not a release dependency.
Blend modes (sprites and layers)
The direction: choose how a sprite or character layer blends with what is behind it. A small set of common modes ships first (the usual multiply / add / screen family). Extra Photoshop-style looks would come from small built-in shaders you pick by name, not from a free-form editor.
What is not ready: author keys, a finished way to write it in the script, and docs for when blend mode fights with existing shaders or save/restore. Today, tint and opacity are the safe tools — Sprites and objects · Shaders.
Not scheduled here (but missing today)
Skipping already-read text, gamepad controls, and picture thumbnails on save slots — listed under What VN-Mikro handles for you. Thumbnails sit with the unfinished Save/Load UI above.
Panel height ceiling and body scroll for dialogue text are available now (theme max_height / sticky panel max_height). Choice-list scrolling inside a capped plate is still not a separate feature — a choice list taller than the ceiling can still clip.
Next
How you work
Making a game with VN-Mikro is a short loop you repeat all day:
write → play → reload → check → … → build
The two players
| Author player | Your released game | |
|---|---|---|
| Who uses it | You | Your readers |
| Opens any project folder | Yes | No — it contains one game |
| Reload and debug keys | Yes | No |
| Build button | Yes | No |
You write and test in the author player. When the story is ready, you build your released game from it.
Write
Open your project folder in any text editor. Almost everything is in:
story/script.txt— the story itselfgame.json— your game’s name and settings- picture and sound folders
Save the file. That is all “writing” means here — there is nothing to export.
Play and reload
| Key | What it does |
|---|---|
| F5 | Reload the story and pictures, and keep your place (also reloads native extensions in author mode) |
| F6 | Reload only the look (theme, menus) |
| F2 | Open a different project folder |
| F12 | Open the author tools |
Edit a line, press F5, see it. You do not restart the game to test a change.
Check
F12 opens the author tools beside the game. Full tab list (Meta, Nodes jump modes, Vars, Validation, Prefs, Build): Author tools.
Run Validation before you share a build.
Build
When you are ready, the Build tab packs your game for Windows, macOS, Linux, the web or Android. You get one folder per platform to give to players. Details: Build and hand off · Author tools.
How to read these docs
- Parts 1–7 in the sidebar follow the order you build a game in. Read them top to bottom the first time.
- Reference at the bottom is for looking things up once you know what you want.
You do not need to read everything. Most games use only a small part of what is here.
Next
What is JSON — then Reusing settings — then Your first ten minutes
Author tools (F12)
While you are writing, the author player includes a debug overlay. Use it to inspect the running story, jump around, edit flags, validate the project, and open Build.
Player builds you ship do not include this overlay (and they strip the F1–F12 author shortcuts).
Open and close
Press F12 (default; remappable in keybindings.json as the debug toggle action).
Prefs and Build choices are remembered in vn-mikro-debug.cfg next to the player.
Quick keys outside the overlay: How you work (F5 / F6 / F2).
Tabs
Meta
Current node summary, optional raw JSON, and quick controls. The compact status line starts with vn-mikro and the runtime version (separate from your project’s game.json → version).
| Control | What it does |
|---|---|
| Prev / Next | Rollback one history step / leave this node like normal play |
| Force prev | Jump to the previous node in the graph, ignoring play history |
| Advance | Same as Continue (finish typing or a pause tag) |
Nodes
List of story nodes. Sort by story order or id. Jump with:
| Mode | Behaviour |
|---|---|
| simulate | Walk a path to the target and rebuild flags/visuals along the way. Falls back to force if no path is found |
| force | Reconstruct along a path when possible; otherwise enter the node on a blank slate |
You can seed flags before a jump. Jump history backs and forwards through recent targets.
Vars
Live flags: sort by name, type, or recent change. Set, add, remove, or reset values while playing.
Validation
Run project checks and browse warnings/errors with detail text. Useful before a build. Refresh includes broken links, flag-aware reachability (branches that can never run), and missing character / background / screen / sprite refs for the active story pack. Activate a row with a node id to jump there.
When a check fails, the Validation tab label turns red and shows the error count, so you can spot a broken project without opening the tab. F5 re-reads your project files and reruns the checks — fix a missing comma or bracket, press F5, and the error and the red label clear. The Refresh button reruns the same checks without reloading gameplay.
Prefs
| Option | Effect |
|---|---|
| Pause while open | Freeze gameplay while the overlay is open |
| Show FPS | FPS readout |
| Perf log | Performance logging |
| Sprite Tracer | Draw outlines on sprites, character slots, choices, and screen sprites. Kind filters (Sprites / Chars / Choices / Screen sprites) sit under this toggle. Outlines stay on; labels appear on hover. Overlapping same-size layers (for example expression sheets) list under the pointer, top layer first. A sprite or object with shader.margin shows the authored rect solid and the padded glow area dashed |
| Composition Layers | Extra composition-layer outlines on the tracer (needs Sprite Tracer on) |
| Switch Project | Open the project picker (F2 by default) |
| Author Refresh | Reload story/assets and restore current play state (F5). Also reloads declared native extensions in author mode |
| UI Refresh Only | Reload themes/screens/panel without rebuilding gameplay (F6) |
| Width / Dock | Narrow or normal (default); dock left or right |
While the overlay is open, the software cursor is suppressed so the OS pointer stays usable on the tools.
Build
Visible only in the author player. Packaging steps, modes, and what to ship: Build and hand off.
Author vs player
| Author player | Player build | |
|---|---|---|
| Open any project folder | Yes (picker) | No — ships with one game |
| Debug overlay (F12) | Yes | No |
| Hot reload (F5 / F6) | Yes | No |
| Build tab | Yes | No |
You write and test in the author player. When the story is ready, build from the Build tab and distribute that output.
How these docs work
Three layers, one sidebar. You do not need every layer on day one.
Guide (Welcome through Ship)
Short pages in story order: write → set up → direct → stage → look → polish → ship. Read top to bottom the first time. Later, jump in from search or the sidebar.
Reference
When you already know what you want:
| Layer | Job |
|---|---|
| File maps | Whole file at a glance; every key clickable |
| Detail pages | Full tables (commands, story, screens, theme, …) |
| Shared shapes | Transitions, textures, binds, hooks — once, linked everywhere |
| Everything A–Z | Flat name list — browser search (Ctrl+F) |
Guide pages teach when; maps and detail teach every key.
Examples vs docs
examples/ | Playable, incomplete on purpose — teach a slice |
| Docs | Complete — teach the surface |
If an example does not use a key, the key still exists. Check the map or detail page for that surface.
Author tools
Overlay, jumps, flags, Validation, Build: Author tools · Build and hand off.
Checking a project
The author kit can validate your folder (broken jumps, missing assets, schema). Prefer that over guessing from memory — Reference.
What is JSON
JSON is a way to write structured notes in a plain text file. The player reads those notes. You do not “run” JSON like a program — you fill in names and values that the docs list.
If you already know forms and spreadsheets, you are closer than you think: names are column headers, values are cells, and groups of fields are like a row under a heading.
A tiny example
{
"title": "Cold Down",
"window": {
"width": 1280,
"height": 720
}
}
In words:
- Curly braces
{ }wrap a group of named fields. "title"is a name (always in double quotes)."Cold Down"is the value for that name."window"is another group nested inside the first one.
Commas separate fields. The last field in a group has no comma after it.
The shapes you will see
| Shape | Looks like | Means |
|---|---|---|
| Text | "hana" | Names, paths, dialogue keys |
| Number | 0.5, 14 | Amounts, seconds, sizes |
| On / off | true / false | A switch (always lowercase) |
| Empty / clear | null | None — for example stop music, or clear a setting |
| Group | { "a": 1, "b": 2 } | Named fields together |
| List | [ "a", "b" ] or [ {…}, {…} ] | Items in order |
Colours are usually text like "#343a52e0". Paths are text relative to your project folder.
Why VN-Mikro uses it
The script is great for lines and flow. JSON is better when many small settings must stay together and stay checkable:
- Your game’s name and defaults (
game.json) - How the dialogue box and menus look (
themes/) - Title, pause and settings screens (
screens.json) - Precise direction on one moment (
story/story.json) - Layered characters, choice looks, cursors
You do not write JSON for every line of dialogue. Most of the story stays in script.txt. Settings files hold everything that is not a spoken line.
The file maps show every allowed name in a file. Stick to those names so projects stay stable and easy to edit later — including with help from an assistant.
Common mistakes (and how they feel)
| Mistake | What happens |
|---|---|
| A missing comma, or a comma after the last field | The file is skipped and defaults apply; author tools report the file and line of the problem |
| Missing quotes around a name | Invalid JSON — same report with file and line |
True / False (capital T/F) | Use true / false |
Comments (// or #) | Standard JSON has no comments — put notes in a README instead |
Single quotes '…' | Use double quotes "…" |
A good editor underlines broken JSON. The author tools (F12 → Validation) and project check also name the file and line for broken settings JSON (game.json, themes, screens, characters, story overlays).
You do not need to memorise it
Copy an example from these docs or from examples/, change the values, reload (F5). Open a settings page only when you need a key.
When one look or expression should start from another and change only a few things, see Reusing settings.
Next
Reusing settings · then Pictures, sound, and folders · then Your first ten minutes
Reusing settings
You often want this: start from something that already works, change only what differs. VN-Mikro does that in several places. The idea is the same; the words and rules are not. This page keeps them apart.
When a later chapter says extends or source, it will point at one row of the table below — not at “inheritance” in general.
The kinds (start here)
| What you reuse | Where you write it | How you say it | What happens |
|---|---|---|---|
| A character expression | characters/<id>/*.json | "extends", "override", "add" | Copy another expression’s layers, then patch layers or append new ones |
| A dialogue-box look | themes/… → panel.<name> | "extends" | Merge another panel preset’s keys |
| A button / choice / name-entry look | a pack file under themes/ | "source" or "style" | Load that pack, then merge your local keys on top |
| Splash per platform | game.json → splash.web / android / desktop | nested blocks | Platform block merges over the shared splash root |
| A whole mood for a scene | story command theme_style | profile name | That theme file merges over the base theme |
| Text or looks per language | locale overlays / string catalogs | locale files | Language layer sits on top of the base |
Objects under sprites/*.obj.json do not reuse another object this way. They are a flat list of parts; you change an instance with update and live links to flags. See Sprites and objects.
One example per kind
Character expression (layers)
"default": [
{ "path": "base.png" },
{ "path": "mouth.png" }
],
"happy": {
"extends": "default",
"override": [{ "layer": 1, "path": "mouth_happy.png" }]
}
happy starts as default, then replaces layer 1. "add" would append extra layers. This is not the same tool as panel "extends".
Dialogue-box look (keys)
"panel": {
"default": { "width": "100%", "bg": "#1e2133e0" },
"whisper": {
"extends": "default",
"width": "60%",
"bg": "#00000099"
}
}
whisper keeps everything from default except the keys you set. Full story: Theme and the dialogue box.
Pack look (source / style)
{ "style": "choice_plain" }
{ "source": "choice_plain", "gap": 12 }
"style": "name" means the same as "source": "name". The pack loads from themes/<name>.json, then your local keys merge on top. Used for buttons, choices and name-entry — not for character packs, cursors, or name-plate style keys.
Splash per platform
"splash": {
"logo": "splash/logo.png",
"web": { "logo_max_width": 280 },
"desktop": { "min_seconds": 0.6 }
}
Shared keys at the root; web / android / desktop only change what that platform needs. Details: Title, icon, splash.
Shared merge rule (deep merge)
These places merge key by key (panel extends, pack source, splash platforms, scene mood files):
| In your file | Effect |
|---|---|
| Key omitted | Keep the base value |
| Key present | Use yours |
null | Clear the base value |
| A list (array) | Replace the whole list — not item by item |
Character expression "extends" is different: it works on layers (override / add), not this key table alone.
Do not mix these
| Easy mix-up | Reality |
|---|---|
Character "extends" vs panel "extends" | Same English word; different tools. Characters = layers; panels = keys. |
"source" / "style" vs character packs | Pack compose is for controls. Character art uses its own pack files and expression extends. |
Sticky story overrides (theme_style, choice_style, node panel) | “Use this until I clear it” during play — not a JSON extends between files. |
Defaults in game.json | Project defaults (typing, layout, session). They are not inheritance between files. |
Object parts | No object-to-object extends. Parts are edited with update / binds. |
When you need this page
- First time you copy a look and change one field → read the matching row above.
- Later chapters link here instead of re-explaining merge rules.
Glossary: Words used in these docs.
Next
Pictures, sound, and folders · then Your first ten minutes
Pictures, sound, and folders
Lives in: your project folder
The script names people and places. Those names only work if the files exist. The simplest game still needs pictures (and usually a little sound) on disk — not only script.txt.
This page is the map. Deeper character packs come under Characters; props under The stage.
A tiny project that can run
my-game/
game.json required — name and defaults
story/
script.txt your play
characters/
hana/
hana.png default look
hana_happy.png optional expression
backgrounds/
street.jpg a place
audio/ optional at first
bgm/
sfx/
themes/
default.json dialogue-box look (copy from an example)
screens.json title / pause (copy from an example)
Copy examples/example-00-boilerplate or example-01-hello-vn and replace pictures — you do not need to invent game.json from scratch on day one.
Characters = folders of pictures
| On disk | In the script |
|---|---|
Folder characters/hana/ | Speaker HANA: and direction [hana left] |
hana.png (or hana_default.png) | Default look |
hana_happy.png | HANA [happy]: or [hana happy left] |
Rules that matter:
- One folder per character id — folder name is the id (
hana), case-insensitive in the script. - File name =
idorid_expression, plus an image extension. - You do not need a JSON pack for a first game. One PNG per expression is enough.
- Layered packs (blinking, talking mouths) are optional later — Characters, Reusing settings.
People often call these “sprites.” In VN-Mikro, character pictures live under characters/. The separate sprites/ folder is for props and gadgets on top of the scene — optional; skip it until you need a lamp, phone overlay, or HUD widget.
Places (background pictures)
| On disk | In the script |
|---|---|
backgrounds/street.jpg | [bg street] or [bg street fade 0.5] |
Write the name without the extension. Formats: .png, .jpg / .jpeg, .webp, .svg.
Sound (when you want it)
| On disk | In the script / settings |
|---|---|
audio/bgm/theme.ogg | [bgm theme] |
audio/sfx/door.mp3 | [sfx door] |
same sfx/ folder | typing beeps and button clicks in game.json |
Formats: .ogg or .mp3. Name without extension in commands.
Title music can also be set once in game.json — Music and sounds by default.
Formats at a glance
| Kind | Folder | Formats | Named in script as |
|---|---|---|---|
| Character | characters/<id>/ | .png .jpg .jpeg .webp .svg | id / expression |
| Place | backgrounds/ | .png .jpg .jpeg .webp .svg | [bg name] |
| Music | audio/bgm/ | .ogg .mp3 | [bgm name] |
| Sound effect | audio/sfx/ | .ogg .mp3 | [sfx name] |
What can wait
| Folder / file | When you need it |
|---|---|
sprites/ | Props, meters, clickable objects — Sprites and objects |
fonts/, richer themes/ | Custom look — Look and menus |
Character *.json packs | Layered art, drive — Characters |
shaders/, animations/ | Polish |
extensions/ | Custom extension packs — Native extensions |
| Extra story packs | Early multi-scripting — In development |
Next
Your first ten minutes if you have not run an example yet · otherwise What a script is
Your first ten minutes
You will open a small example, change it, and see your change play.
1. Open the example
- Start the author player.
- When it asks for a project, pick
examples/example-01-hello-vn. - Click through a few lines. Space, a mouse click, or the Continue button moves on.
2. Open the script
Open examples/example-01-hello-vn/story/script.txt in any text editor. You will recognise what you just saw:
[bg street fade 0.5]
Hello! This is the smallest story you can run.
[hana left fade 0.45]
HANA:
Hi - I'm Hana.
- A line in square brackets is a stage direction: show the street, bring Hana in on the left.
HANA:means Hana speaks the lines below it.- A line with no speaker is the narrator.
- An empty line starts the next moment of the story.
3. Change something
Change Hana’s line:
HANA:
Hi! I rewrote this line myself.
Save the file, go back to the player, press F5. Hana now says your line.
Other keys you will use (F2, F6, F12): How you work.
4. Add a moment
Add these lines at the end of the file, after an empty line:
[bg parknchairs fade 0.5]
[hana left fade 0.4]
HANA [closed]:
The park is quieter than I thought.
Press F5 and click through to the end. You changed the place, brought Hana back, and gave her a different expression (closed uses the picture hana_closed.png).
Those pictures live under characters/ and backgrounds/ — Pictures, sound, and folders.
5. Break something on purpose
Change parknchairs to parkchairs (a typo), save, press F5, then press F12 and open Validation. The missing background is listed. Fix the typo and reload.
That is the whole loop: write, reload, check.
Where next
If settings files are new to you: What is JSON and Reusing settings. What those [bg] / [hana] lines load from disk: Pictures, sound, and folders.
Continue with What a script is. The rest of the examples in examples/ add one idea each:
| Example | Adds |
|---|---|
example-00-boilerplate | An empty project to copy for your own game |
example-02-choices-and-flags | Choices and remembering decisions |
example-03-character-composition | Characters built from layers (blinking, talking) |
example-04-presentation-toolbox | Per-character panels, focus dimming, a clickable hotspot |
example-05-ui-and-polish | Menus, HUD, look |
example-06-native-extension | Custom extension packs (parallax, click particles) |
example-07-phone-and-terminal | Phone chat + terminal panels (append, typing, input) |
cold-down | A complete tiny visual novel |
What a script is
Lives in: story/script.txt
A VN-Mikro script is meant to read like a play or screenplay: who speaks, what they say, and short stage directions for the scene. The player turns that text into the game.
You write it in any text editor. Empty lines matter. Square brackets and a few marks do the rest.
Scenes, moments, and lines
| Word | In the file | What the player does |
|---|---|---|
| Moment | One block of text between empty lines | Runs that block’s directions, types the text, then waits for a click (or a choice / timer) |
| Scene | A moment with a name: :: park | Something you can jump to later |
| Line | Text under a speaker, or narrator text | What appears in the dialogue box |
An empty line ends the current moment and starts the next. That is the main “cut” in the script.
A short scene, read top to bottom
:: street
[bg street fade 0.5]
[hana left fade 0.4]
HANA:
You came back.
> Of course. -> stay
> Just passing through. -> leave
:: stay
HANA:
Good. Stay a while.
-> END
:: leave
HANA:
Safe travels.
-> END
What happens:
:: streetnames this moment so other moments can jump here.[bg …]and[hana …]are stage directions — they run when the moment starts.HANA:marks who speaks; the next lines are her dialogue.>lines are choices. Each can jump with-> ….-> ENDends the story.
The marks you will use most
| Mark | Example | Job |
|---|---|---|
| Empty line | New moment | |
# | # note | Comment — ignored by the player |
:: | :: park | Name this moment |
:: start | :: start | Optional New Game entry (first moment if omitted) |
NAME: | HANA: | Speaker |
[…] | [bg street] | Stage direction or flag |
> | > Stay. -> stay | Choice |
-> | -> park or -> END | Jump / end |
{…} | {score} / {@w} | Flag in text, or a pause mark |
Everything else builds on these.
Dialogue, narration, directions — together
[bg park fade 0.5]
The park is quiet.
[hana left]
HANA:
Sit with me.
- No speaker → narrator (or the story’s voice).
HANA:→ her name plate and focus.- Bracket lines → change the stage; they are not spoken aloud.
Those names only work if the pictures exist on disk (characters/, backgrounds/, …) — Pictures, sound, and folders.
What you do not put in the script
Looks (fonts, dialogue-box colour), title menus, and dense production usually live in other files (game.json, themes/, screens.json, sometimes story/story.json). The script stays readable on purpose.
One project normally has one story folder and one script.txt. Several scripts in one game (episodes / packs) is early — In development.
When a moment needs more than brackets can say: When the script is not enough.
Next
Open examples/example-01-hello-vn/story/script.txt while you read.
Lines and speakers
Lives in: story/script.txt
Who talks, how their name appears, and how each line waits for a click.
Speaker marks at a glance
| Write | Effect |
|---|---|
HANA: | Speaker = folder characters/hana/ |
HANA [happy]: | Same + stage that expression |
???|HANA: | Plate shows ???; talking still hana |
HANA & SORA: | Both in focus |
HANA (silent): | Same speaker; typing mouth / beep off (see below) |
HANA [happy] & SORA (silent): | Per name: expression and/or silent |
These can combine, for example ???|HANA [happy] (silent): or ???|HANA [happy] & SORA (silent):.
Speakers
HANA:
Hello.
HANA: (any case) maps to the folder characters/hana/. The name plate usually shows that character’s display name. What to put in that folder: Pictures, sound, and folders.
Several lines under the same speaker stay in the same moment until an empty line:
HANA:
First click.
Still Hana — same moment, another click.
Expression while speaking
HANA [happy]:
Good morning.
That looks for characters/hana/hana_happy.png (or the pack’s happy look). Folders and naming: Pictures, sound, and folders. Details later: Characters.
Narrator
A line with no NAME: is narrator voice — no name plate from a character:
The street is empty.
Unknown name / custom plate
Show a different name on the plate while still using a character folder:
???|HANA:
Who's there?
Left of | is the plate text; right is who is talking (for focus and assets).
Two speakers at once
HANA & SORA:
Together:
Both are in focus for that line. One name plate covers the group (auto names, or a custom Label|… for the whole line). The line uses the routed panel for this moment — usually the first speaker’s preferred panel, unless you set panel_id in data — How text finds a panel.
Each name can take its own [expression] and/or (silent):
HANA [happy] & SORA (silent) & DADDY [sad] (silent):
They speak as a group.
| Segment | Effect |
|---|---|
HANA [happy] | In the group; stage happy; mouth on while text types |
SORA (silent) | In the group; mouth off |
DADDY [sad] (silent) | In the group; stage sad; mouth off |
To silence everyone, mark each name: HANA (silent) & SORA (silent):.
Silent line (no talking mouth)
HANA (silent):
(She doesn't answer.)
Same speaker and plate; typing mouth (and beep) stay off. Only the literal (silent) mark — not free-form (anything). Mouths in depth: Talking and focus.
Style inside a line
BBCode works in dialogue:
HANA:
That was [b]not[/b] the plan.
[color=#ff8888]Careful.[/color]
Unknown [word] tags that are not commands stay visible as text. To show real brackets: [lb] / [rb].
Print a flag in the text
HANA:
Your score is {score}.
Curly braces insert the current flag value. To print a literal {, write {{.
Usually you need
| Want | Write |
|---|---|
| Someone speaks | NAME: then lines |
| Expression | NAME [happy]: |
| Narrator | plain lines |
| Mystery plate | ???|NAME: |
| Joint + per-name mods | NAME [happy] & NAME (silent): |
| Silent (no mouth) | NAME (silent): |
| Style | [b], [i], [color=#…] |
| Remembered value | {flag} |
Next
Stage directions
Lives in: story/script.txt
Bracket lines change the scene. They are not spoken. They run when the moment starts — unless they appear after dialogue has already begun, in which case they fire mid-line as {@cN} cues; see Pacing.
[bg street fade 0.5]
[hana left fade 0.4]
[bgm city_loop]
[sfx door]
Places
[bg street]
[bg street fade 0.5]
[bg park slide_left 0.6]
Loads a picture from backgrounds/ (extension resolved for you). Optional fade / slide and duration. Naming: Pictures, sound, and folders.
More: Backgrounds.
Characters
[hana left]
[hana happy left fade 0.4]
[hana hide]
[sora right fade 0.3]
- First word is the character folder name (
characters/hana/). left/center/rightare places on stage (defaults; you can rename them later).- An expression word (
happy) and a fade are optional. hideremoves them from their place.
Speaking with HANA [happy]: also stages that expression. Bracket lines are for show / move / hide without a line of dialogue.
Pictures on disk: Pictures, sound, and folders. Packs and places: Characters.
Music and sound
[bgm theme]
[bgm theme fadein 1.5]
[sfx door]
Files live under audio/bgm/ and audio/sfx/. Title music defaults belong in game.json — Music and sounds by default. Mid-story cues: Music and sound.
Flags (also brackets)
[score = 0]
[helped = true]
[score += 1]
Same bracket shape, different job: memory, not staging. Full use: Choices and memory.
Why several directions feel “instant”
All enter directions in one moment run as a batch when that moment starts. That is why two [hana …] and [sora …] lines can appear together. To time something with the spoken line, put the bracket after text has started — Pacing ({@cN}).
Comments
# This is only for you — the player ignores it
Usually you need
| Want | Example |
|---|---|
| Change place | [bg street fade 0.5] |
| Show character | [hana left fade 0.4] |
| Hide character | [hana hide] |
| Music / SFX | [bgm theme] / [sfx door] |
| Set a flag | [helped = true] |
Keyword directions also include things like panel, screen, animate — look them up in Reference when you need them. Character folder names without a keyword are treated as show/move/hide for that character.
Next
Scenes and jumps
Lives in: story/script.txt
Name moments and move between them so the story can branch and meet again.
Name a scene
:: park
HANA:
We're here.
:: park sets this moment’s id to park. Use letters, numbers, and underscores. Every jump target must match an id (or the special end).
If you omit ::, the player still builds a moment — it just has no stable name to jump to. Named scenes are how you build routes.
Where New Game starts
New Game looks for a moment named start (JSON: "id": "start"). In the script that is:
:: start
[bg street fade 0.5]
HANA:
Good morning.
You can omit :: start. The player then uses the first moment in the file. Small examples do that on purpose. Name it when you care about a stable entry (saves, jumps to START, or a pack that sets a different entry).
For several stories in one project, each pack can set its own entry id with stories[].start — Several stories.
Jump
-> park
alone on a line (or after text in some setups) sets where to go when this moment finishes. Common pattern:
HANA:
Let's go.
-> park
End the story
-> END
Ends play (then credits / title follow your session settings — Save, Back and story end).
Branch and merge
:: choice
> Left path. -> left
> Right path. -> right
:: left
HANA:
Left.
-> after
:: right
HANA:
Right.
-> after
:: after
HANA:
Both roads meet here.
Both branches jump to the same after moment. That is how routes rejoin.
Visit a beat and come back
For a reusable insert (a menu of facts, a short cutaway), use call and return:
[call encyclopedia]
…and at the end of that scene:
[return]
call remembers where you were; return goes back. Prefer plain -> for normal story flow; reserve call/return for side visits.
Conditional jump
[if helped -> thank_you]
If the expression is true, go there instead of the normal next moment. Expressions use flag names and && / || / ! — Choices and memory.
Order tips
- Give every important moment a
::id early — renaming later can break old saves. - Keep merge points (
:: after) obvious in the file. - One
->per moment for the default exit; choices carry their own targets.
Usually you need
| Want | Write |
|---|---|
| Name | :: park |
| New Game entry | :: start (optional — first moment if omitted) |
| Go there | -> park |
| End | -> END |
| Choice target | > Text. -> park |
| Side visit | [call id] … [return] |
| If flag | [if helped -> thank_you] |
Next
Try examples/example-02-choices-and-flags/story/script.txt.
Choices and memory
Lives in: story/script.txt
Let the reader decide, and remember what they decided.
Open examples/example-02-choices-and-flags/story/script.txt while you read.
Flags
A flag is something the story remembers for the rest of the playthrough (and in saves).
[score = 0]
[helped = false]
[player_name = 'Hero']
Change later:
[score += 1]
[helped = true]
Print in dialogue:
HANA:
Score is {score}. Helped is {helped}.
Strings use single quotes inside the bracket: [some_text = 'hello'].
Choice menus
:: park
HANA:
What do you do?
> Help her. -> help_branch
> Stay back. -> skip_branch
:: help_branch
[helped = true]
HANA:
Thanks.
-> after
:: skip_branch
HANA:
Alright.
-> after
:: after
HANA:
helped is {helped}.
- Each
>line is one option. -> labelis where that option goes.- A bare
>with no arrow continues to the next moment in file order (NEXT).
Look and shuffle defaults live in game.json — Choice and character layout. Rich choice rules (show only if…, disable if…) need the direction file — Adding direction.
Branch on a flag
[if helped -> thank_you]
or, with more cases in data form, jump_if — same expression language. Use &&, ||, ! (not the words and / or).
[if score > 3 && helped -> good_ending]
Ask for a name
[input player_name "Your name?" default "Hero"]
The field starts empty with ghost text Hero. Enter with nothing typed stores Hero; or the reader types a name. On confirm the flag player_name is set. To start with real text the reader can edit, use prefill instead of default:
[input player_name "Your name?" prefill "Hero"]
Then:
HANA:
Nice to meet you, {player_name}.
More options (max_length, pattern, …): Reference when you need them. Look of the field: Choice and input looks.
Usually you need
| Want | Write |
|---|---|
| Remember | [flag = value] / += / -= |
| Show value | {flag} in text |
| Menu | > Caption. -> target |
| Gate a jump | [if expr -> target] |
| Name entry | [input flag "Prompt?"] |
Try this
- Add a third choice that does
[score += 2]. - Print
{score}after the branches meet. - Play twice and pick different options.
- After the branches meet, ask for a name with
[input]and use{player_name}in an ending.
Next
Pacing
Lives in: story/script.txt
Control the rhythm of reading: pauses, speed, auto-advance, and fades when leaving a moment.
Soft pause in the line
HANA:
Wait…{@w} there.
{@w} waits for a click (skipped when the reader holds fast-forward). Timed pause:
HANA:
One second…{@w=1.0} go.
Clear the page, then continue
HANA:
First page.{@p}Second page.
{@p} pauses and clears the dialogue box on resume. It is not skipped by fast-forward.
Change speed mid-line
HANA:
Slow.{@cps=8} Faster now.{@cps}
{@cps=N} sets letters per second; {@cps} resets to your project default (Text speed).
Auto-advance a moment
After the text finishes, move on without a click:
HANA:
No click needed.
[auto 2]
[auto 2] means “auto-advance after 2 seconds.” Ignored when the moment is a choice menu.
Exit directions (leave cleanly)
Run directions when leaving this moment — useful for fades out before the next place enters:
:: goodbye
HANA:
See you.
[exit: hana hide fade 0.4]
[exit_delay 0.5]
-> street
[exit: …]— a stage direction that runs on leave (same language as enter brackets).[exit_delay 0.5]— wait after those exit directions before the next moment.
Enter directions still run at the start of the next moment as usual.
Directions in the middle of a line
A bracket after spoken text has already started fires when typing reaches that word — not when the moment begins:
HANA:
I was fine.
[sfx click]
Then I wasn't.
The script compiler inserts an invisible {@c1} (then {@c2}, …) at that spot and stores the direction in flow_commands. Typing does not pause; the command runs as the cursor passes the mark.
Prefer {@w} / {@cps} for timing-only. Use a mid-line bracket when something must fire at a word. In Mode B / JSON you can write {@c1} in text yourself and fill flow_commands — Command flow. Dense cases: Directing.
Usually you need
| Want | Write |
|---|---|
| Click pause | {@w} |
| Timed pause | {@w=1.0} |
| New page | {@p} |
| Speed change | {@cps=12} / {@cps} |
| Fire a direction at a word | Mid-line […] (becomes {@cN}) |
| Auto-advance | [auto 1.5] |
| Fade out on leave | [exit: …] + [exit_delay …] |
Try this
- Put
{@w}in the middle of a line; click to resume. - Put
[sfx …]after a spoken line has started; hear it fire mid-type. - Add
[auto 1.5]on a short narrator moment. - Hide a character with
[exit: …]into the next scene.
Next
Writing techniques
Lives in: story/script.txt
Common visual-novel patterns, written in the script. Steal these shapes; change the words.
Inner voice vs spoken line
Use a plain line (no NAME:) for narrator voice and thoughts; a normal speaker line for speech:
The letter feels heavier than it should.
HANA:
I'm fine.
Or keep the same character, silent, so the mouth does not move (how (silent) works):
HANA (silent):
(Don't say it.)
HANA:
Everything's fine.
Flashback with a different look
A simple script-only flashback: change place, maybe hide the present cast, mark the voice, then jump back.
:: present
[bg street]
HANA:
I keep thinking about that day.
-> flashback
:: flashback
[bg park fade 0.8]
# Optional: switch dialogue-box look for this stretch — see Look / theme_style
Three years earlier.
HANA [closed]:
We should have left sooner.
-> present_return
:: present_return
[bg street fade 0.8]
HANA:
…Anyway.
For a lasting mood change (dimmer panel, different colours), use a second panel preset, a sticky panel command, or a theme_style mood file — Theme and the dialogue box · How a panel looks.
Phone or chat messages
Treat the phone as narrator lines or a dedicated “speaker” folder with its own pictures:
[phone center]
PHONE:
hey, you free?
HANA:
In a minute.
Or plain narrator with style:
[color=#88aaff]hey, you free?[/color]
Free-floating bubbles and custom layouts need direction data — Sprites and objects, Adding direction.
Split into routes, then join
:: fork
> Trust her. -> route_a
> Walk away. -> route_b
:: route_a
[trusted = true]
HANA:
Thank you.
-> join
:: route_b
[trusted = false]
HANA:
…Alright.
-> join
:: join
HANA:
Either way, we keep moving.
[if trusted -> trusted_extra]
-> after_join
:: trusted_extra
HANA:
I'm glad you stayed.
-> after_join
:: after_join
…
One merge moment (join) keeps the file readable. Optional extras use [if …].
A scene the reader can revisit
Share one labeled moment from several places with call / return:
:: hub
> Ask about the park. -> ask_park
> Leave. -> leave_hub
:: ask_park
[call park_info]
-> hub
:: park_info
HANA:
It's quieter after dark.
[return]
Normal story flow still uses ->. Call/return is for side visits that should resume where you left off.
Name entry at the start
:: start
[bg street fade 0.5]
What should we call you?
[input player_name "Your name?" default "Hero"]
-> intro
:: intro
HANA:
Nice to meet you, {player_name}.
Put name entry before the first long scene so {player_name} works everywhere after.
Where to go next
You can draft a whole game with only this part. When you need project defaults (text speed, saves, Back): Setting up your game. When brackets are not enough for one moment: Directing a scene.
The game.json file
Lives in: game.json
Every project has one settings file in the project root. The player reads it when the game starts. Anything you leave out uses a sensible default.
A minimal file
{
"title": "My Novel",
"window": { "width": 1280, "height": 720 }
}
That is enough to open a named window at a base size. Story text, choices and pictures still come from story/ and your asset folders.
A fuller starter (still small):
{
"title": "My Novel",
"version": "0.1",
"window": { "width": 1280, "height": 720 },
"typing": { "cps": 28 },
"session": { "allow_back": true }
}
See examples/example-00-boilerplate/game.json and examples/example-01-hello-vn/game.json.
What most games touch
| Block | Job | Page |
|---|---|---|
title, window, version, authors | Name and window | Name, window, language |
typing | How text appears | Text speed and voice beeps |
sounds | Title music, beeps, UI clicks | Music and sounds by default |
session | Saves, Back, title / end | Save, Back and story end |
layout | Choice menus and character places | Choice and character layout |
Later parts of the handbook cover the rest (transitions, cursor, splash, screens look, and so on). Those keys still live in this same file. Advanced: custom extension packs under extensions.
Full map
Every allowed key in one view: game.json file map. Click a key to open the page that teaches it.
Usually you need
| Want | Do |
|---|---|
| Name the game | "title": "…" |
| Set design size | "window": { "width", "height" } |
| Faster / slower text | "typing": { "cps": … } |
| Allow rollback | "session": { "allow_back": true } |
Next
Name, window, language
Lives in: game.json
What the window is called, how large the picture is drawn, and which language the game starts in.
Title and version
{
"title": "My Novel",
"version": "1.0.0",
"authors": ["Your Studio"]
}
| Key | Effect |
|---|---|
title | Name on the window, app file, and build folder |
version | Your game’s version (used in build names) |
authors | Credits; also the Windows app’s company field |
To take the title from a translations file instead, use title_key — Translations.
Window size
"window": {
"width": 1280,
"height": 720
}
This is the base size your art and layouts are written for. The player scales the picture to whatever window or screen the reader uses.
Starting language
"locale": "en"
That is the language the game opens in. Offering several languages in Settings is i18n — again Translations.
App icon
"icon": "icon.png"
A picture in the project folder, converted for each platform when you build. Details: Title, icon, splash.
Usually you need
| Want | Write |
|---|---|
| Window name | "title": "…" |
| Design size | "window": { "width": 1280, "height": 720 } |
| Credits / company | "authors": ["…"] |
Full key list: game.json map.
Next
Text speed and voice beeps
Lives in: game.json → typing
How dialogue letters appear, and the optional voice beep while they type. Mid-line pauses and speed tags stay in the script — Pacing. A panel preset can override most of these keys for one surface — Panel.
Letters per second
"typing": {
"cps": 28,
"cps_fast": 100
}
| Key | Effect |
|---|---|
cps | Letters per second (0 = whole line at once) |
cps_fast | Speed while the reader holds advance / fast-forward |
instant | Always show whole lines (true wins over a positive cps) |
speed_scale | Overall multiplier (readers can change this in Settings) |
auto_advance_default | Seconds after a line before moving on; 0 = wait for a click |
If you omit cps, the player can use delay / delay_fast (seconds per letter) instead. speed_scale and auto_advance_default are Settings-owned — they cannot be set on a panel preset.
How letters look
"typing": {
"cps": 28,
"reveal": "typewriter"
}
reveal | Look |
|---|---|
typewriter | Each letter snaps on (default) |
soft | Letters fade in |
fade_line | The current clause fades as a whole |
pop / rise | Fade plus a small scale or lift |
Optional extras (pending_alpha, reveal_soft, caret mark, …) are on the game.json map.
Voice beep
"typing": {
"beep": {
"sample": "typing_beep",
"volume": 0.5,
"pitch_min": 0.9,
"pitch_max": 1.1
}
}
| Key | Effect |
|---|---|
enabled | Play the beep (false silences this surface / the project default) |
sample | Sound id under audio/sfx/ |
volume | 0–1, multiplied by master × SFX |
pitch_min / pitch_max | Random pitch range |
Ids point at files in your audio folders — Pictures, sound, and folders. Readers can turn the beep off in Settings (sounds.defaults.typing_enabled seeds the first launch). Script [typing_sfx …] overrides the sample until reset — typing_sfx.
Line start / end sounds
Optional one-shots when a whole line begins or finishes typing. Useful for phone chat (“incoming message”) without writing [sfx] on every node. Not fired on {@w} pauses, and not on Back / load restore. Uses the SFX bus (master × SFX × volume) — the Typing Sound toggle does not mute these.
"typing": {
"start_sfx": { "sample": "msg_in", "volume": 0.6 },
"end_sfx": { "sample": "msg_done", "volume": 0.3 }
}
Same objects work on a panel preset — Typing per preset.
Mouth motion while typing is a character pack feature, not a typing key — Talking and focus. Script (silent) turns it off for a speaker — Lines and speakers.
Usually you need
| Want | Write |
|---|---|
| Comfortable speed | "typing": { "cps": 28 } |
| Instant lines | "typing": { "cps": 0 } or "instant": true |
| Soft fade | "reveal": "soft" |
| Beep sample | "typing": { "beep": { "sample": "…" } } |
| Incoming message ping | "typing": { "start_sfx": { "sample": "msg_in" } } |
Next
Music and sounds by default
Lives in: game.json → sounds
Title music and UI clicks. The typing beep lives under typing.beep — Text speed and voice beeps. Scene music and one-shot effects in the script are separate — Music and sound.
Title music
"sounds": {
"main_bgm": "title_theme",
"main_bgm_volume": 0.8,
"main_bgm_fade_in": 1.5,
"main_bgm_fade_out": 1.5
}
main_bgm is a music id from your audio folders. Fade keys are in seconds; volume is 0–1.
UI clicks
"sounds": {
"button_click": "click",
"button_hover": "hover",
"button_volume": 0.5
}
Used for menus, Continue / Back on the dialogue panel, and normal choice buttons (unless a look pack overrides them).
Typing beep
Set typing.beep — Text speed and voice beeps.
First-launch volumes
"sounds": {
"defaults": {
"master": 1.0,
"bgm": 1.0,
"sfx": 1.0,
"typing_enabled": true
}
}
Seeds Settings the first time someone plays. After they save prefs, those win.
Usually you need
| Want | Write |
|---|---|
| Music on the title | "main_bgm": "…" |
| Menu click | "button_click": "…" |
| Quieter UI | "button_volume": 0.25 |
Ids and folders: Pictures, sound, and folders. Full key list: game.json map.
Next
Save, Back and story end
Lives in: game.json → session · save files next to the player · screen actions
A session is one playthrough from New Game / Continue / Load until you return to title (or quit). Progress lives next to the player as manual slots, one autosave, and a separate prefs file.
Early — not final UX. Multi-slot save/load grids and multi-story packs are shipped and usable, but menus, labels, hub screens, and overall player comfort are still a first cut. Prefer the simple one-slot
"save"/"load"wiring unless you need the grid or packs — In development.
Most stories
- Keep pause/title hooks as
"save"/"load"for a simple one-slot feel (always slot 1). - Or point menus at
screen:save/screen:loadfor a multi-slot picker — define your ownsave/loadscreens with thesave_slotsblock; none ship built-in. - Title Continue resumes the autosave.
- Set
session.allow_backif you want Back / rollback. - Set
session.post_story_screen(and usuallypost_story_return_to_title) for credits after the last line. - Changing node ids or restructuring the story can invalidate old saves — that is intentional.
Where this lives
| Idea | File / surface | Path |
|---|---|---|
| Back + story-end + slot count | game.json | session |
| Continue / Load visibility | screens.json | show_if: has_autosave / has_save |
| Slot picker UI | screens.json | your own save / load screens + save_slots block |
| Files on disk | Next to the player | see below |
{
"session": {
"allow_back": true,
"back_retype": false,
"back_limit": 0,
"save_slots": 9,
"boot": "title",
"hud_screen": "hud",
"post_story_screen": "credits",
"post_story_return_to_title": true
}
}
"hud_screen": "hud" (default) auto-shows that screens.json entry with role: "hud" when a session starts. Set "hud_screen": "" to disable. Extra HUD mid-story: screen command.
{ "label": "Save", "interaction": { "hooks": { "on_click": "save" } } }
{ "label": "Save game", "interaction": { "hooks": { "on_click": "screen:save" } } }
Players never write save files by hand. Authors configure session and the screen actions that read those files.
session keys
| Key | Type | Default | Effect |
|---|---|---|---|
allow_back | bool | false | Enable Back / history. History is only stored in saves when this is true |
back_retype | bool | false | After Back: false finishes typing instantly; true retypes the line |
back_limit | int | 0 | Max Back steps per forward stretch; 0 = unlimited |
save_slots | int | 9 | Manual slot count (clamped 1–99) |
default_story | string | first pack / main | Pack id for bare new_game — Several stories |
boot | string | "title" | Cold start: "title" or "new_game" (skip title) |
post_story_screen | string | "" | Screen id after the story ends (e.g. "credits") |
post_story_return_to_title | bool | true | End session and show title (then optionally the post-story screen) |
hud_screen | string | "hud" | HUD id at session start; "" disables |
If post_story_screen is empty and post_story_return_to_title is false, the runtime still returns to title.
Map: game.json → session.
Files on disk
| File | Role |
|---|---|
save-1.json … save-N.json (author) or save-N.sav (player build) | Manual slots |
autosave.json / autosave.sav | Continue only |
prefs.json | Volumes, display, UI prefs, last_used_slot, and (when using multi-story) last_story_id / campaign (always plain JSON) |
| Manual slot | Autosave | |
|---|---|---|
| Written by | Player Save / save_slot:N / key S | Runtime after progress; also after successful Back, Title/Quit flush, window close |
| Read by | Load / load_slot:N / key L (in play) | Continue only |
| Preferences | No — see prefs.json | No |
Immediate actions: "save" / "load" always use slot 1.
Keyboard: S quick-saves the last-used slot (fallback 1); L in play loads that slot. Title L still prefers Continue when an autosave exists.
New Game starts a fresh session; it does not delete these files.
Title buttons typically use show_if: "has_save" / "has_autosave". Those checks also require a compatible file, so broken or outdated saves hide Continue/Load. has_save is true if any slot is occupied and compatible.
Author vs player encode
| Mode | When | Slot / autosave format |
|---|---|---|
| Plain JSON | Editor and author-mode player | .json — readable for debugging |
| Obfuscated | Production game build | .sav — not casually readable in a text editor |
Obfuscation is comfort (raise the bar vs peeking), not DRM. Prefs stay plain so volumes/locale remain easy to inspect.
Where files live
| Context | Location |
|---|---|
| Player build (one shipped game) | saves/ next to the executable |
| Author player (many projects) | saves/<project-id>/ — id is a slug from game.json title |
| Web build | Browser storage — survives reload when storage is allowed; private mode and some iframe blocks do not |
Saves are player progress, not part of your project folder.
What a save holds
Story cursor: current node id, flags, shuffle seed, plus save_version, story_id (pack), and story_hash for compatibility.
Stage and chrome (restored in a fixed order): background, character layout profile, on-stage characters, BGM, sprites, objects, screen shader, running animations, panels, theme_style / choice_style overrides, cursor profile.
Also: rollback history (only if allow_back), dialogue backlog. Preferences live only in prefs.json (volumes, display, UI, last_used_slot, last_story_id, campaign unlock flags).
Node-local overrides are not kept; drivers re-sync after load or Back.
Multiple stories (early)
One shared slot list + one autosave for the whole game. Each save stamps story_id. Hub screen: new_game:<id> and optional show_if: story_unlocked:<id>. Slot labels can use {story} / {story_title}. Full contract: Several stories in one project.
Back / rollback
Requires allow_back, enough history, and room under back_limit.
- History snapshots are taken after a node’s command batch finishes.
- Back restores the previous snapshot and skips “transit” nodes (no text and no choices) so the player lands on something interactive.
- Successful Back also writes a live autosave.
- Backlog trims to the restored node.
Default Back key is remappable (dialogue_back in keybindings.json) — Keys.
Story end
When there is no valid next (or a choice targets null):
- Fade out.
- Clear autosave — Continue is gone for that finished run.
- End session (reset stage/flags; title if configured).
- Fade in.
- Optionally push
post_story_screen.
Manual slots and prefs are not cleared on story end.
Usually you need
| Want | Write |
|---|---|
| Allow Back | "allow_back": true |
| Skip title (test) | "boot": "new_game" |
| Credits after ending | "post_story_screen": "credits" |
| More save slots | "save_slots": 12 |
Related
Title, pause and settings screens · Gameplay HUD · save_slots content block · Sticky state and clear · Several stories
Choice and character layout
Lives in: game.json → layout
Project defaults for choice menus, name entry, and where characters stand. A story moment can override these for one scene; this file sets the usual look.
Choice menus
"layout": {
"choices": {
"show_numbers": true,
"shuffle": false,
"gap": 10,
"style": "choice_button"
}
}
| Key | Effect |
|---|---|
show_numbers | Prefix choices with 1., 2., … |
shuffle | Mix the order (still stable for a given play) |
gap | Space between stacked choices |
style | Button look pack under themes/ |
container and item hold where the menu sits and per-button defaults — shapes shared with other UI: Shared shapes. Visual packs: Choice and input looks. Writing the options themselves stays in the script — Choices and memory.
Name entry
"layout": {
"text_input": {
"style": "input_default",
"confirm": { "label": "OK" }
}
}
Defaults for [input …] moments. Field placement and chrome use the same container shapes as choices.
Character places
"layout": {
"characters": {
"mode": "adaptive",
"adaptive": {
"slot_count": 3,
"slot_width": 400,
"slot_height": 720
}
}
}
| Mode | Meaning |
|---|---|
adaptive | Even row of places (left / center / right by default) |
explicit | You name each place with your own positions |
Script stage directions like [hana left] use those place names. Full explanation: Planes and slots · Places on stage.
Usually you need
| Want | Write |
|---|---|
| Numbered choices | "choices": { "show_numbers": true } |
| Wider choice buttons | "choices": { "item": { "layout": { "width": 480 } } } |
| Three standing places | "characters": { "mode": "adaptive", "adaptive": { "slot_count": 3 } } |
Full key list: game.json map.
Next
When a line of script is not enough: When the script is not enough. Or jump to The stage for pictures and music in scenes.
Several stories in one project
Lives in: game.json → stories
Early — not final UX. Multi-story packs are implemented (catalog, save
story_id, unlock /clear_flag,new_game:<id>). There is no polished shipped story-select / campaign UI — you wire a hub inscreens.jsonyourself. Labels, hub layout, and comfort flows may change.
Optional catalog of story packs in one project (episodes or separate stories). Omit the key for a single pack at story/ (id main).
"stories": [
{
"id": "main",
"title": "Episode 1",
"path": "story",
"start": "start",
"mode": "episode",
"unlock": { "always": true },
"clear_flag": "ep1_cleared"
},
{
"id": "ep2",
"path": "stories/ep2",
"mode": "episode",
"unlock": { "flag": "ep1_cleared" }
}
],
"session": {
"default_story": "main"
}
Usually you need
| Idea | How |
|---|---|
| One story | Omit stories — same as today |
| Hub menu (DIY) | screens.json screen whose button hook is "on_click": "new_game:ep2" |
| Lock episode 2 | "unlock": { "flag": "ep1_cleared" } + "clear_flag" on episode 1 |
| Slot labels | occupied_label: "Slot {n} — {story_title} — {node}" |
All pack keys
| Key | Type | Default | Effect |
|---|---|---|---|
id | string | required | Stable pack id (saved as story_id) |
path | string | "story" | Folder under the project root with script.txt and/or story.json |
start | string | "start" | Entry node id for New Game; if that id is missing, the first moment is used (Scenes and jumps) |
mode | string | "anthology" | episode or anthology |
title | string | "" | Display name (slot labels); fallback = id |
title_key | string | "" | Locale key for title when set |
unlock | object | {always:true} | { "always": true } or { "flag": "<name>" } (reads prefs.campaign) |
clear_flag | string | "" | On story end in episode mode, set this key true in prefs.campaign |
Session
| Key | Type | Default | Effect |
|---|---|---|---|
session.default_story | string | first pack / main | Pack for bare new_game and boot graph |
Actions / show_if
| Surface | Effect |
|---|---|
new_game | Start default_story |
new_game:<id> | Start that pack if unlocked |
continue / load_slot:N | Activate pack from the save’s story_id, then restore |
story_unlocked:<id> | True when unlock passes against prefs.campaign |
Saves
All packs share one slot list and one autosave (same early multi-slot surface). Each save stores story_id. Load grid tokens: {story}, {story_title} (plus {n}, {node}).
In development · Save, Back and story end · Screens · game.json map
When the script is not enough
Lives in: story/script.txt and (when needed) story/story.json
Most of a game stays in the script: speakers, lines, jumps, choices, and short stage directions. Add a direction file only when a moment needs more than brackets can say cleanly.
What the script is good at
| Job | In the script |
|---|---|
| Who speaks, what they say | HANA:, lines, (silent), joints |
| Simple stage | [bg …], [hana left], [bgm …] |
| Flow | :: id, ->, >, [if …] |
| Pacing | {@w}, {@cps=…}, [auto …], mid-line […] / {@cN} |
That is enough for a whole first draft — Writing your story.
When brackets get awkward
Reach for story/story.json when you need things like:
- An object or widget tied to a flag (a meter, a lamp)
- Choice menus with custom layout or look packs in detail
- Dense animation, shaders, or panel chrome on one beat
- A command that should run only if a condition is true (
when) - Something that sticks for the session, or lasts only this moment (
apply)
If you can say it in one clear bracket line, keep it in the script.
Three ways to work
| Way | Files | Use when |
|---|---|---|
| Script only | script.txt | Most games, most moments |
| Script + direction | script.txt + story.json | Dialogue in the script; heavy staging in data |
| Data only | story.json alone | Rare — whole story as data entries |
With script + direction, each direction entry matches a scene by id (the same name as :: park). Dialogue and jumps stay in the script; the data file adds or overrides what that moment needs.
Start in the script
- Write and play the scene as script.
- Give important moments a stable
:: id. - Only then add a
story.jsonpatch for the moments that need it.
Next: see one moment in both shapes — The same beat as data.
The same beat as data
Lives in: story/script.txt and story/story.json
A moment is the same thing whether you write it as script or as a data entry. The player runs one shape either way.
Side by side
Script:
:: park
[bg street fade 0.5]
[hana left]
HANA [happy]:
Watch the meter.
-> after
Same moment as data (fields the player cares about):
{
"id": "park",
"talking": "hana",
"text": "Watch the meter.",
"commands": [
{ "type": "bg", "value": "street", "transition": "fade", "duration": 0.5 },
{ "type": "char", "name": "hana", "position": "left" },
{ "type": "char", "name": "hana", "expression": "happy" }
],
"next": "after"
}
You rarely type the whole data form by hand. The script is sugar for this. Direction files usually hold only the extra fields you could not say in brackets.
Fields most moments use
| Field | Meaning |
|---|---|
id | Scene name (:: park) |
talking | Who is speaking (one id, or several) |
label | Custom name-plate text |
text | Dialogue / narrator lines |
commands | Stage directions at the start of the moment |
choices | Choice menu |
next | Where to go after (or null to end) |
auto | Auto-advance seconds ([auto N]) |
driver_typing / driver_typing_targets | Mouth drive on/off or limited to some speakers |
Mid-line tags ({@w}, {@cN}, {@talk:…}) live inside text. Batches for {@cN} sit in flow_commands; leave actions use exit_commands — Adding direction to a script scene · Command flow.
Watch it live
In the author player, F12 → Nodes lists every moment. Open one to see the live fields after script and direction are merged. Vars shows flags; Validation catches broken jumps and missing assets — How you work.
Usually you need
| Want | Do |
|---|---|
| Read what a moment became | F12 → Nodes |
| Add only staging | Patch in story.json by id — next page |
| Invent a whole game in JSON | Possible, but script stays easier for dialogue |
Full map
Every moment field in one view: story/story.json file map. Full tables for choices, script sugar, and Mode B: Story detail.
Next
Adding direction to a script scene
Adding direction to a script scene
Lives in: story/script.txt + story/story.json
Keep dialogue and flow in the script. Put precise staging in a direction entry with the same id.
Name the scene
:: park
HANA:
Watch the meter.
-> after
The :: park name is the link. Without a stable id, the direction file cannot find the moment.
Add a patch
story/story.json is a list of objects. Each object matches one script moment:
[
{
"id": "park",
"commands": [
{
"type": "obj",
"object": "meter",
"instance_id": "score_meter",
"show": true,
"x": 16,
"y": 16
}
]
}
]
How merge works
| Rule | Effect |
|---|---|
Match by id | Direction entry attaches to that script moment |
| Most fields | Direction replaces what the script had |
commands | Direction commands are appended after script brackets |
| Fields you omit | Script values stay |
So the line and HANA: stay in the script; the meter object is only in the direction file.
A worked meter / flag bind lives in the presentation examples under examples/ (search for story.json next to a script.txt).
When commands run
| When | Where you put them |
|---|---|
| Start of the moment | Script brackets, or commands in the direction entry |
| Middle of the text | Mid-line […] → {@cN} + flow_commands, or write those fields in direction / data |
| Leave the moment | exit_commands / exit_delay — fade or cleanup on the way out |
Everyday timing stays in the script: Pacing. Use direction when the mid-line or leave action is too heavy for a bracket. Batches and indexing: Command flow.
Choices and other overlays
You can also replace or flesh out choices, panel fields, auto, and similar on the matched id. Prefer replacing only what you must; leave speakers and text in the script when they are already clear.
Usually you need
| Want | Do |
|---|---|
| Extra staging on one beat | { "id": "…", "commands": [ … ] } |
| Keep script stage too | Rely on append — do not duplicate every [bg] |
| Whole new text | Put text in the direction entry (it replaces the script text) |
| Every command’s fields | Commands |
Next
Reacting to flags live · Commands
Reacting to flags live
Lives in: story/story.json (and object / sprite packs)
Make something on screen follow what the story remembers — a meter, a lamp, a command that only runs if a condition is true.
A flag drives a picture
Set a flag in the script:
[score = 3]
HANA:
Watch the meter.
An object (or sprite part) can bind a property to that flag — position, colour, and similar — so it updates when the flag changes. The bind lives on the object file; the moment only shows the object:
{
"id": "park",
"commands": [
{
"type": "obj",
"object": "meter",
"instance_id": "score_meter",
"show": true
}
]
}
Building the meter parts and bind map: Sprites and objects. Characters can bind opacity, modulate, scale, and shader uniforms the same way — Expressions and packs. Worked examples also live under examples/.
Flags themselves: Choices and memory.
Run a command only if…
On a command in the direction file, add when with an expression:
{
"type": "sfx",
"value": "chime",
"when": "score >= 3"
}
If the expression is false (or errors), that command is skipped; the rest of the moment continues. Expressions use the same language as [if …] — Choices and memory.
when is data-only (no script sugar for it yet).
Things that stick, things that last one beat
Some commands change the stage for the whole play until you change them again. Others should apply only this moment.
| Meta | Effect |
|---|---|
omit / "apply": "persistent" | Stays until override, reset, or new game (default where supported) |
"apply": "node_local" | Reverts after this moment |
Used on surfaces such as panel look, name plate, objects, and some layouts. To clear a sticky override, send that command family again or use "reset": true where the command allows it.
For many shaders: leave the key out = keep what is there; set the key to null = clear. Check the stage / polish page for that surface when you use it.
Usually you need
| Want | Do |
|---|---|
| Meter follows a flag | Object/sprite bind + show the object on the moment |
| SFX only if helped | "when": "helped" on the command |
| Panel look for one whisper | "apply": "node_local" on that panel command |
Next
Stage pictures and sound in depth: Backgrounds. Or return to Setting up your game for project defaults.
Sticky state and clear
Some story commands change the stage for the whole session. Others last one moment. Omitting a key often means “leave as-is”; null often means “clear.” Getting that wrong is how backgrounds and shaders “stick forever.”
Most stories
- Default: stage changes persist until you change them again (or reset / new session).
- Use
"apply": "node_local"when a panel / layout / labelplate should revert after this beat. - Use
"reset": true(or an explicit clear) when a sticky override must go away. - For shaders and similar: omit = keep current;
null= clear. - Character
modulate/opacity/scaleare not sticky — set them again on eachcharcommand when you want them to carry on — Characters on stage.
Where this lives
| Idea | Surface |
|---|---|
| Sticky vs one-moment | Command meta apply |
| Undo sticky | reset: true on that command family |
| Keep vs clear | Absent key vs null (per field) |
| Session end | New game clears sticky stage |
{ "type": "panel", "preset": "whisper", "apply": "persistent" }
{ "type": "panel", "preset": "whisper", "apply": "node_local" }
{ "type": "labelplate", "reset": true }
{ "type": "bg", "value": "street", "shader": null }
apply
| Value | Effect |
|---|---|
"persistent" (default where supported) | Sticks until reset, overridden, or new session |
"node_local" | Applies for this moment’s cycle, then reverts |
Used on (among others): panel, labelplate, char_layout, obj, screen_shader. Shape: Shared shapes.
when is separate: skip this command if the expression is falsy (JSON / Mode B only).
Absent vs null (common sticky fields)
| Field family | Omit key | Explicit null |
|---|---|---|
bg / char / sprite shader | Keep current | Clear shader |
Node panel_shader | Clear (unlike sticky stage shaders) | Clear |
bgm / typing_sfx value | — | Stop / restore default |
screen_shader value | — | Clear post-process |
Always check the command’s detail page when unsure — a few surfaces treat absent differently (panel shader on the node is the common surprise).
How to undo
| Sticky thing | Undo |
|---|---|
| Panel preset override | Another panel command, or reset where supported |
| Labelplate override | { "type": "labelplate", "reset": true } |
| Char layout profile | { "type": "char_layout", "reset": true } |
Choice pack from choice_style | { "type": "choice_style", "reset": true } |
Theme mood from theme_style | { "type": "theme_style", "reset": true } |
| Shader on stage | Same surface with "shader": null |
Save / back: sticky stage is part of cumulative save; node_local is not meant to outlive the moment. Save, Back and story end.
Related
Command flow · Shared shapes · Shaders · Commands
Planes and slots
Two ideas decide where things appear on screen. A render plane decides what draws on top of what. A slot is a named place on the character plane where one character stands.
Everything on one screen: planes stack from back to front, slots hold characters left to right.
Most stories
You can ignore most of this page until you need it.
- Background at the back, characters in the middle, dialogue panel in front.
- Default layout gives you three slots:
left,center,right. Write[hana left]and you are done. - Put a character over the panel with
draw_layer: "above_panel"on thecharcommand (or in their pack / slot). - Switch arrangements mid-scene with
char_layoutonce you have named profiles ingame.json.
Where this lives
| Idea | File / surface | Path |
|---|---|---|
| Slot layout | game.json | layout.characters |
| Character over/under panel | char command or pack | draw_layer / ui.draw_layer |
| Screen stacking | screens.json | role (scene / hud / modal) |
| Sprite band | sprite / obj command | layer (bg_front / fg / overlay) |
| Panel chrome slots | themes/default.json | panel.<preset>.slots |
{
"layout": {
"characters": {
"mode": "adaptive",
"adaptive": { "slot_count": 3, "slot_width": 600 }
}
}
}
{ "title": { "role": "modal" } }
{ "type": "sprite", "value": "alert", "layer": "overlay" }
Keys: Choice and character layout · Theme: panel for panel slots.
Render planes
Planes are fixed. You cannot add or renumber them; you choose which plane your content goes to.
| Plane | Layer | What lives there | How you put something there |
|---|---|---|---|
bg | 0 | Background image, bg_front sprites | bg, sprite with layer: "bg_front" |
scene_ui | 0 | Screens with role: "scene" | screens.json |
chars | 0 | Character slots, fg sprites | char, sprite with layer: "fg" |
panel | 0 | The dialogue panel | panel command, node panel* fields |
above_panel | 1 | Characters lifted over the panel | char with draw_layer: "above_panel" |
overlay | 3 | overlay sprites | sprite with layer: "overlay" |
hud | 10 | Screens with role: "hud" | screens.json |
notify | 50 | Toast notifications | notifications in game.json |
modal | 75 | Screens with role: "modal" (the default) | screens.json |
cursor | 90 | The software cursor | cursors.json / cursor |
transition | 100 | Full-screen fade between scenes | transitions |
Higher layer numbers draw on top. Four planes share layer 0, so their order comes from scene tree order instead, bottom to top: background, scene UI, characters, panel. That is why a role: "scene" screen sits behind your characters while a role: "hud" screen sits in front of everything in the story.
Where the screen shader goes
screen_shader is not on the ladder above. It moves itself based on target. Details: Shaders.
target | Layer | Ends up covering |
|---|---|---|
"game" (default) | 2 | Background, scene UI, characters, panel |
"scene" | 74 | Everything below modal screens |
"all" | 99 | Everything except the transition fade |
Sprite layers
The sprite command has three layers, and they are not one contiguous band — they interleave with characters and the panel:
layer | Draws above | Draws below |
|---|---|---|
"bg_front" | Background image | Characters, panel |
"fg" | Background | Panel, above_panel characters |
"overlay" | Panel and above_panel characters | HUD screens |
Character slots
A slot is a rectangle on the chars plane that holds one character. Each slot has two names:
- A slot id (
slot_0,slot_left, …) — internal, used inside a layout profile. - An alias (
left,center,right, …) — what you write in a story:position: "left".
You always write aliases. The alias-to-slot mapping belongs to a layout profile, and switching profiles is how you rearrange the stage mid-scene.
Profiles come from layout.characters in game.json, in one of two modes.
Adaptive mode (default)
You state how many characters can be on screen and how big they are; slot rectangles are generated to fit the window.
{
"layout": {
"characters": {
"mode": "adaptive",
"base_texture_size": 600,
"adaptive": {
"slot_count": 3,
"slot_width": 520,
"snap": "bottom",
"margin": 0
}
}
}
}
| Key | Type | Default | Effect |
|---|---|---|---|
slot_count | int ≥ 2 | 3 | How many slots are generated |
slot_width | int ≥ 1 | base_texture_size | Slot width in pixels |
slot_height | int ≥ 1 | slot_width if width was set, else base_texture_size | Slot height in pixels |
side_margin | int ≥ 0 | 20 | Inset from the left and right window edges |
slot_gap | int or null | null | Pixels between slots. null divides the remaining width evenly. Negative values overlap slots on purpose |
snap | "bottom" | "top" | "bottom" | Which window edge slots align to |
margin | int | 0 | Distance from the snapped edge |
z_pattern | string | "center_front" | "center_front" gives the middle slot z: 2 and the rest z: 1. Any other value leaves every slot at z: 1 |
auto_selection_policy | string | "nearest_center_out" | Order used when placing a character that has no position |
alias_strategy | "canonical" | "indexed" | "custom" | "canonical" | How aliases are named |
aliases | array of strings | — | Required when alias_strategy is "custom"; must have exactly slot_count unique, non-empty entries |
base_texture_size sits one level up, directly under layout.characters, and defaults to 600.
Canonical alias names depend on the slot count:
slot_count | Aliases, left to right |
|---|---|
| 2 | left, right |
| 3 | left, center, right |
| 4 | far_left, left, right, far_right |
| 5 | far_left, left, center, right, far_right |
| 6 or more | slot_1 … slot_N |
"indexed" always gives slot_1 … slot_N regardless of count. "custom" uses your aliases array in left-to-right order; if the array is the wrong length or has duplicates, it falls back to canonical names and warns.
Explicit mode
You place every slot by hand. Use this when characters should not be evenly spaced, when slots overlap deliberately, or when different scenes need different arrangements.
{
"layout": {
"characters": {
"mode": "explicit",
"explicit": {
"active_profile": "default",
"profiles": {
"default": {
"aliases": { "left": "slot_left", "right": "slot_right" },
"auto_selection_policy": "nearest_center_out",
"slots": [
{ "id": "slot_left", "x": 0, "y": 20, "width": 700, "height": 700, "z": 1, "order": 0 },
{ "id": "slot_right", "x": 580, "y": 20, "width": 700, "height": 700, "z": 1, "order": 1 }
]
},
"closeup": {
"aliases": { "center": "slot_big" },
"slots": [
{ "id": "slot_big", "x": 290, "y": 0, "width": 900, "height": 900, "z": 2, "order": 0 }
],
"transition": { "kind": "fade", "duration": 0.35 }
}
}
}
}
}
}
A profile has four keys:
| Key | Type | Effect |
|---|---|---|
slots | array | The slot rectangles |
aliases | object | Maps alias to slot id. An alias pointing at a slot id that does not exist is dropped with a warning |
auto_selection_policy | string | "nearest_center_out" (default) orders aliases by slot order, breaking ties alphabetically. "center_priority" tries the alias literally named center first, then falls back to that same order |
transition | object | Default { "kind", "duration" } used when a char_layout command switching to this profile does not name its own |
A slot has these keys:
| Key | Type | Default | Effect |
|---|---|---|---|
id | string | required | Slot id referenced by aliases; must be unique in the profile |
x, y | number | 0 | Top-left corner, in window pixels |
width, height | number | base_texture_size | Slot size. The character’s art is composed inside this box |
z | number | 0 | Draw order within the character plane; higher is in front |
order | number | index | Tie-breaker for z, and the sequence used by auto placement and remapping |
draw_layer | "behind_panel" | "above_panel" | inherited | Overrides the plane for characters in this slot |
modulate | HTML color | #ffffff | Slot tint, multiplied with the char command tint and any pack drive tint |
scale | number or [sx, sy] | 1 | Slot scale, multiplied the same way |
Which plane a character ends up on
draw_layer is resolved in this order, first match wins:
draw_layeron thecharcommandui.draw_layerin the character’s pack JSONdraw_layeron the slot in the layout profile"behind_panel"
Placing a character without naming a slot
When a char command omits position:
- If that character is already on screen, they stay in their current slot.
- Otherwise the first free alias in
auto_selection_policyorder is used. - If every alias is taken, nothing is shown and a warning is logged.
Showing a character in a slot also removes them from any other slot, instantly and without a transition, so a character can never be in two places.
Switching profiles mid-scene
The char_layout command activates a different profile.
[char_layout closeup fade 0.35]
[char_layout reset]
Characters keep their alias across the switch. If the new profile still defines that alias, the character moves to the new rectangle. If it does not, the character overflows: they are hidden with a 0.2 second fade, and a warning names the alias and character. Two characters resolving to the same slot id is also an overflow for the second one, ordered by the old slot order.
policy decides what happens when anything overflows:
policy | Behaviour |
|---|---|
"best_effort_remap" (default) | Apply the new profile, hide the overflowing characters |
"strict" | Roll back to the previous profile and change nothing |
transition decides how slots move. "instant" snaps. "fade" glides surviving slots to their new position over duration seconds, easing out. If the command omits transition, the target profile’s own transition block is used; if that is also missing, the switch is instant. A fade with no duration anywhere uses 0.3 seconds.
Only slots that exist before and after the switch glide. Characters appearing or disappearing are handled by their own char show and hide transitions.
Panel slots
The dialogue panel has its own small slot system for chrome such as a speaker portrait pinned to the panel. Those are declared in the theme under panel.<preset>.slots, not in layout.characters, and they follow the panel rather than the window. See Theme: panel. Stage props: Sprites and objects. UI screens: Title, pause and settings screens.
Backgrounds
Lives in: backgrounds/
Places your story happens in. Folders and formats: Pictures, sound, and folders. Script sugar: Stage directions.
Pictures on disk
| On disk | In the story |
|---|---|
backgrounds/street.jpg | [bg street] or [bg street fade 0.5] |
Write the name without the extension. Formats: .png, .jpg / .jpeg, .webp, .svg.
Change place
[bg street]
[bg street fade 0.5]
[bg park slide_left 0.6]
[bg clear]
| Piece | Effect |
|---|---|
| Name | Load that picture |
fade / slide_* | How it enters (default for a plain [bg name] is a short fade) |
| Duration | Seconds |
clear | Remove the background |
As data:
{ "type": "bg", "value": "street", "transition": "fade", "duration": 0.5 }
value: null clears. Same-track music rule does not apply here — each bg sets the place.
Effects on a background
You can attach a shader (and optional parameters) on the background command. Leave the shader key out to keep what is there; set it to null to clear. Full shader authoring: Shaders. Sticky omit vs null: Sticky state and clear.
Command fields opacity, modulate, and visible set how the plate looks; live binds can drive the same look from flags — including image to swap the picture (same stems as value, under backgrounds/). Bind image swaps are instant; use [bg … fade …] when you want a transition.
Day / night from a flag:
{
"type": "bg",
"value": "street",
"bind": {
"image": {
"flag": "time",
"map": { "day": "street", "night": "street_night" },
"default": "street"
}
}
}
Prefer a sprite/object for small overlays; keep bg for the full scene picture. Show/hide hooks on the command still work as elsewhere.
Richer rooms
Backgrounds that behave like multi-layer places (hotspots, room camera) are still early — In development. Ship with single pictures and [bg …] today.
Usually you need
| Want | Write |
|---|---|
| Show a place | [bg street fade 0.5] |
| Clear | [bg clear] |
| Slide in | [bg park slide_left 0.6] |
Next
People on stage: What a character is · then Sprites and objects
Sprites and objects
Lives in: sprites/
Props and gadgets on top of the scene — not character folders. Skip this folder until you need a chair, lamp, phone overlay, or flag-driven meter.
A picture on the scene (sprite)
Sprites are data-only (direction file / story data — no [sprite …] sugar in the script today):
{
"type": "sprite",
"id": "chair",
"value": "chair",
"layer": "bg_front",
"x": 625,
"y": 494
}
| Field | Effect |
|---|---|
id | Name of this prop instance |
value | Picture name under sprites/ (or null to hide) |
layer | Draw band: bg_front, fg, or overlay |
x / y | Position |
You can also draw a plain rect or polygon (kind) instead of an image. Optional fade, tint, shader (one per sprite), and live binds work here too.
Reusable objects (*.obj.json + obj)
A catalog object is a file like sprites/meter.obj.json: an id, a default layer, and a list of parts (images or shapes).
{
"id": "meter",
"layer": "overlay",
"parts": [
{ "id": "bg", "kind": "rect", "width": 40, "height": 120, "color": "#343a52e0" },
{
"id": "ptr",
"kind": "polygon",
"points": [[12, 0], [0, 10], [12, 20]],
"bind": {
"y": { "flag": "score", "map": { "1": 90, "3": 50, "5": 10 }, "default": 50 }
}
}
]
}
Show an instance on a moment:
{
"type": "obj",
"object": "meter",
"instance_id": "score_meter",
"show": true,
"x": 16,
"y": 16
}
| Idea | Meaning |
|---|---|
object | Which catalog file |
instance_id | This copy on stage (you can show the same def twice with different ids) |
update | Patch parts on an existing instance |
bind | Live link from a flag to a property (on the def, the instance, or a part) |
apply | Sticky for the session, or node_local for one moment |
A shader on an object applies to the whole instance (all parts composited), not per part.
Objects do not use character-style extends. Parts are a flat list; change them with update and binds — Reusing settings.
Putting the obj on a script scene: Adding direction to a script scene. Flag-driven examples: Reacting to flags live.
Usually you need
| Want | Do |
|---|---|
| One prop picture | sprite with value + position |
| Meter / widget | *.obj.json parts + obj show |
| Follow a flag | bind on a part |
Full map
objects file map · Objects detail
Next
Music and sound
Lives in: audio/bgm/ and audio/sfx/
Music beds and one-shot effects during the story. Title defaults and UI clicks live in game.json — Music and sounds by default. Folders: Pictures, sound, and folders.
Names and formats
| On disk | In the story |
|---|---|
audio/bgm/theme.ogg | [bgm theme] |
audio/sfx/door.mp3 | [sfx door] |
Formats: .ogg or .mp3. Use the name without the extension.
Music
[bgm theme]
[bgm theme fadein 1.5]
[bgm stop]
As data:
{ "type": "bgm", "value": "theme", "volume": 0.7, "fadein": 1.0 }
{ "type": "bgm", "value": null, "fadeout": 1.0 }
| Key | Effect |
|---|---|
value | Track name; null / stop clears |
volume | Loudness 0–1 |
fadein / fadeout | Seconds |
fadein_from / fadeout_to | Volume fractions at the ends of the fade |
Playing the same track again does not restart it (volume can still update). Different track with both fade-in and fade-out → crossfade.
Title screen music uses sounds.main_bgm* in game.json, not these command defaults.
Sound effects
[sfx door]
{ "type": "sfx", "value": "door" }
One-shot. Optional volume and similar fields exist in data form. Button clicks and typing beeps are separate defaults under sounds — Text speed and voice beeps.
On a moment vs on a button
| Where | Typical use |
|---|---|
| Script / moment commands | Story stings, room ambience changes |
game.json → sounds | Title BGM, UI click / hover, typing beep |
| Screen / choice packs | Per-button overrides when you customise menus |
Usually you need
| Want | Write |
|---|---|
| Start music | [bgm theme fadein 1.5] |
| Stop music | [bgm stop] |
| Door sting | [sfx door] |
Next
Look of the dialogue box and menus: Theme and the dialogue box. Or directing heavy staging: When the script is not enough.
What a character is
Lives in: characters/<id>/
A character is a folder whose name is the id (hana). The script uses that id for speakers (HANA:) and stage directions ([hana left]). Folders and file names: Pictures, sound, and folders.
The simple path
characters/hana/
hana.png
hana_happy.png
[hana left fade 0.4]
[hana happy left]
[hana hide]
HANA [happy]:
Good morning.
| Rule | Meaning |
|---|---|
| Folder name | Character id (case-insensitive in the script) |
hana.png / hana_default.png | Default look |
hana_happy.png | Expression happy |
| No JSON required | Enough for a first game |
HANA [happy]: both speaks and stages that expression. Bracket lines show, move, or hide without a line of dialogue — Stage directions.
What comes next in this part
| Page | When you need it |
|---|---|
| Expressions and packs | Layered art, pack root / ui, char_pack, tint / shader / bind |
| Expression fades | Soft swaps between looks (vs show/hide fade) |
| Talking and focus | Mouths, silent lines, focus / choice drive |
| Places on stage | Slots, layouts, above/behind the dialogue box |
| Motion and frames | Flipbook layers; idle / 3D still early |
Props and backgrounds are not characters — The stage.
Usually you need
| Want | Do |
|---|---|
| Add a person | Folder + default PNG |
| Show them | [hana left] |
| Change look | hana_happy.png + HANA [happy]: or [hana happy left] |
Next
Expressions and packs
Lives in: characters/<id>/*.json
When one PNG per look is not enough, a pack lists stacked pictures (layers) for each expression. Same folder as the PNGs; often named default.json.
A small pack
{
"display_name": "Hana",
"default": [
{ "path": "base.png" },
{ "path": "mouth.png" }
],
"happy": {
"extends": "default",
"override": [{ "layer": 1, "path": "mouth_happy.png" }]
}
}
| Idea | Meaning |
|---|---|
| Expression key | default, happy, … — what [hana happy] and HANA [happy]: ask for |
| Layers | Stacked pictures (body, face, mouth…) drawn bottom → top |
"layer": 1 in override | Index into that stack (0 = first layer) |
extends | Copy another expression’s layers, then change them |
override | Replace layers by index |
add | Append extra layers |
This is layer reuse, not the same as panel "extends" — Reusing settings.
Unknown expression names fall back to default.
Pack root (not layers)
Keys at the top of the file describe the character, not one expression:
| Key | Effect |
|---|---|
display_name | Name shown on the name plate (usual case) |
display_name_key | Translation catalog key instead of a hard-coded string |
ui | Preferred panel / name-plate / draw plane — see Pack ui below |
drive | Live reactions while talking or focused — Talking and focus |
composition_transitions | How expression swaps fade — Expression fades |
<expression> | Layer list or extends / override / add object |
Reserved filenames (not packs): sprites.json, objects.json, metadata.json, profiles.json.
Pack ui
| Key | Effect |
|---|---|
draw_layer | "behind_panel" (usual) or "above_panel" — Places on stage |
default_panel_id | Preferred dialogue panel for this character — How text finds a panel |
default_panel_preset | Panel look preset name — How a panel looks |
default_labelplate_style_key | Name-plate look for this speaker — Name plate |
panel_slots | Portrait / art inside the box — Speaker art on the panel |
Layer keys
A layer is usually a picture; it can also be a flipbook — Motion and frames.
| Key | Effect |
|---|---|
path | Image in the character folder (__blank = empty slot) |
x / y | Offset in pixels |
w / h | Size override |
modulate / scale / opacity | Tint, size, alpha for that layer |
drive | Show only while a driver is on — e.g. "typing", "!typing", "focus" — Talking and focus |
frames + fps / loop / … | Flipbook instead of a single path |
Several pack files
Keep a simple default.json and a richer full.json (or similar). Switch with:
[char_pack hana full]
{ "type": "char_pack", "name": "hana", "pack": "full" }
Or set "pack": "full" on a single char command. char_pack alone does not redraw — the next show / expression change uses the new pack.
If the named pack is missing, the player tries default, then the first pack it finds.
Locale overlays
For a translated art set, add default_ja.json (or <pack>_<locale>.json) beside the base pack. The player picks the overlay that matches the active language when it exists.
Simple PNGs still work
If there is no pack, the player keeps using hana.png / hana_happy.png. Adding a pack is optional polish, not a rewrite of the script.
Story look (tint, shader, bind)
Separate from the pack file — on a char command (direction file or data):
| Want | Field |
|---|---|
| Tint / fade / size for this beat | modulate, opacity, scale (repeat on later commands if you want them to stick) |
| Effect on the character | shader / shader_params (or null to clear) — one shader over the whole slot, not per layer — Shaders |
| Follow a flag while on stage | bind for opacity, modulate, scale, shader:… — Reacting to flags live |
{
"type": "char",
"name": "hana",
"position": "left",
"opacity": 0.85,
"bind": {
"opacity": { "flag": "trust", "linear": [0, 0.3, 10, 1.0] }
}
}
Show/hide fades in the script ([hana left fade 0.4]) are stage enter/leave — expression-to-expression blends live in the pack — Expression fades.
Usually you need
| Want | Do |
|---|---|
| Body + mouth | Pack with two layers |
| Happy mouth only | "extends": "default" + override on the mouth index |
| Second wardrobe | Another *.json pack + char_pack |
| Name on the plate | display_name (and optional ui.default_labelplate_style_key) |
Full map
Pack keys at a glance: characters pack map. Full tables: Character pack detail.
Next
Expression fades · Talking and focus
Expression fades
Lives in: character pack → composition_transitions
Two different fades:
| Kind | What it is | Where you set it |
|---|---|---|
| Show / hide | Character enters or leaves a place | Script sugar / char — [hana left fade 0.4] |
| Expression swap | Same person, new look while already on stage | Pack composition_transitions (or swap_mode on the command) |
This page is the second kind.
Pack policy
"composition_transitions": {
"default": { "kind": "fade", "duration": 0.2, "swap_mode": "auto" },
"pairs": {
"happy->angry": { "kind": "fade", "duration": 0.4, "swap_mode": "crossfade" }
}
}
| Key | Effect |
|---|---|
default | Used when no more specific rule matches |
pairs | "from->to" overrides (also wildcards like happy->*, *->angry, *->*) |
from / to | Maps keyed by expression id → same payload shape |
Payload
| Field | Values |
|---|---|
kind | instant, fade, slide_up / down / left / right |
duration | Seconds |
swap_mode | auto, in_only, crossfade, out_in, cross_slide |
auto: fade → crossfade; slide → in_only; otherwise in_only.
Resolve order: exact pair → from-wildcard → to-wildcard → *->* → from map → to map → default → instant.
When the command wins
If the char command names a transition (transition, transition_in / transition_out, or a duration), that command wins for that beat — including show/hide.
If the command says nothing about transitions:
- Expression change on someone already visible → pack
composition_transitions - Otherwise → instant
Force blend style with "swap_mode": "crossfade" (and friends) on the command when you need a one-off.
Usually you need
| Want | Do |
|---|---|
| Soft look changes by default | Pack default fade ~0.2s |
| Dramatic one pair | pairs entry for that from->to |
| Instant this beat | Command transition: "instant" or omit pack policy |
Next
Talking and focus
Lives in: character packs (drive on layers / pack root) and the story’s speakers
While text types, the player can drive mouth (and similar) layers. Focus can emphasise who the line is about. Speakers and (silent): Lines and speakers.
Drive reacts to live UI state (typing, focus, open choices). It is not the same as a bind, which follows a story flag — Reacting to flags live.
Mouth layers
"default": [
{ "path": "base.png" },
{ "path": "mouth_closed.png", "drive": "!typing" },
{
"frames": ["m1.png", "m2.png"],
"fps": 10,
"loop": true,
"drive": "typing"
}
]
Layer drive | When that layer shows |
|---|---|
"typing" | While the typing mouth driver is on for this character |
"!typing" | While it is off |
"focus" / "!focus" | While this character is / is not in the current focus set |
"choice_open" / "!choice_open" | While an enabled choice menu is waiting |
Text still types either way. (silent) on a speaker (or on one name in a joint line) turns that character’s typing driver off — mouths stay on the closed layer.
Voice beep is separate (game.json sounds) — Text speed and voice beeps.
Pack-root drive (focus and more)
At the pack root, drive can change the whole look while a driver is on — swap expression, tint, scale, nudge:
"drive": {
"focus": {
"use": "happy",
"priority": 10,
"scale": 1.05
},
"choice_open": {
"modulate": "#ddddff",
"priority": 5
}
}
| Field | Effect |
|---|---|
use | Switch to another expression while the predicate holds |
modulate / scale | Tint / size while active |
offset / rotation_degrees | Nudge / rotate while active |
priority | When several root rules match, higher wins for use; modulate/scale multiply; offsets sum |
transition (optional) | How the drive look eases in/out |
| You want… | Prefer |
|---|---|
| Mouth open/closed while typing | Layer "drive": "typing" / "!typing" |
| Slight “I’m the focus” scale / tint | Root drive.focus with scale / modulate (same expression) |
| Different art while focused / choosing | Root drive with "use": "other_expression" |
| A lasting expression for the scene | Story char / HANA [happy]: (command wins until you change it) |
Drivers are recomputed after load or Back — they are not stored in the save. Flag overlays stay on bind.
Joint lines
HANA [happy] & SORA (silent): — both in the group; only non-silent names get the typing mouth. One name plate for the group — Lines and speakers.
Usually you need
| Want | Do |
|---|---|
| Talking mouth | Layer with "drive": "typing" (+ closed with "!typing") |
| No mouth this line | NAME (silent): |
| Only one mouth in a group | Mark the others (silent) |
| Focus pop | Root "drive": { "focus": { "scale": 1.05 } } |
Next
Places on stage
Lives in: game.json → layout.characters, plus char / char_layout in the story
Where people stand, and whether they draw under or over the dialogue box.
Default places
[hana left]
[sora center]
[emi right]
By default the project offers an even row of places — usually left, center, right. Counts, sizes, and names come from:
"layout": {
"characters": {
"mode": "adaptive",
"adaptive": {
"slot_count": 3,
"slot_width": 400,
"slot_height": 720
}
}
}
| Mode | Meaning |
|---|---|
adaptive | Even row; player names places for you (or you set aliases) |
explicit | You define each place’s position in a named profile |
Project defaults: Choice and character layout. Full key list: game.json map.
Change arrangement mid-story
Use a char_layout command (usually from a direction file) to switch to another profile — close-up, three-wide, off-stage row, and so on — when you have named those profiles in layout.characters.
Behind or above the dialogue box
Characters usually draw behind the dialogue panel so text stays readable. Panel system: What a panel is.
| Setting | Effect |
|---|---|
behind_panel | Default — under the dialogue box |
above_panel | Over the box (rare; big portraits, special beats) |
Set it on the pack (ui.draw_layer) or on a single char command.
This is not the same as sprite/object layer bands (bg_front / fg / overlay) — those are for props — Sprites and objects.
A character shader applies to the whole slot (all layers composited), not to one layer.
Usually you need
| Want | Do |
|---|---|
| Three standing spots | adaptive + slot_count: 3 |
| Custom names / positions | explicit profiles |
| Portrait over the box | draw_layer: "above_panel" |
Next
Motion and frames
Lives in: character pack layers
How a layer can animate while someone is on stage. Soft idle sway and 3D models are still early — see below.
Flipbook layers
A layer can be one picture (path) or a short flipbook:
{
"frames": ["blink1.png", "blink2.png", "blink1.png"],
"fps": 8,
"loop": true
}
| Key | Effect |
|---|---|
frames | Pictures in order (paths in the character folder) |
fps | Frames per second |
loop | Repeat, or play once |
Combine with drive so a mouth flipbook runs only while typing — Talking and focus.
What is not ready yet
| Idea | Status |
|---|---|
| Soft idle breathe / sway on layered packs | Early — In development |
| Blender / GLTF characters on stage | Early — In development |
Ship with still poses and flipbooks you control. Treat idle and 3D as upcoming, not release blockers.
Story animation presets
Separate from pack flipbooks: story animate commands and shared presets — Animation. Use those for scripted moves; use layer frames for blink / mouth cycles on the character itself.
Usually you need
| Want | Do |
|---|---|
| Blink or mouth cycle | Layer frames + fps |
| Mouth only while talking | Same + "drive": "typing" |
Next
Back to the room: Backgrounds · Sprites and objects · Music and sound.
What a panel is
Lives in: theme panel.<preset> + story routing
A panel is the dialogue box — the place text, the name plate, and on-panel choices appear. Most games need only one. Extra panels are for side commentary, whisper boxes, or a second speaker’s sticky line.
Two names that matter
| Word | Meaning |
|---|---|
| Panel id | Which instance on screen (main, side, …). main always exists. |
| Preset | Which look from the theme (default, whisper, …) that instance uses |
Id = which box. Preset = how that box is drawn. You can put look whisper on id main, or look default on id side.
One box is enough
Ship with a single main panel and a default preset. Speakers write into main unless you say otherwise. Name plate and Continue / Back live on that box.
HANA:
Good morning.
No panel fields required.
Where the pieces live
| Piece | Where |
|---|---|
| Look (size, height ceiling, body scroll) | themes/default.json → panel.<preset> — How a panel looks |
| Which box gets this line | Story node / character pack / panel command — How text finds a panel |
| Second boxes, stacking, side logs | Several panels |
| What quiet boxes do | Presence and quiet panels |
| Lines that stack in the box | Appending text |
| Portrait inside the box | Speaker art on the panel |
Fonts and whole-theme colours sit under Look. Character preference for a panel: pack ui — Expressions and packs.
Usually you need
| Want | Do |
|---|---|
| Normal dialogue | Nothing — use main + default |
| A quieter look | Theme preset whisper + node "panel": "whisper" |
| A second box | New panel id — Several panels |
| A tall scrolling log | Preset max_height + sticky append — Several panels |
Next
How text finds a panel
Lives in: story nodes, character packs, and the panel command
Each dialogue moment draws text on one panel — the routed panel. Routing picks the id and the preset.
Which panel id gets the text
First match wins:
- Node field
panel_id - Pack
ui.default_panel_idfor the first speaker on the line (talking[0]) - Otherwise
main
{
"id": "side_note",
"panel_id": "side",
"talking": ["hana"],
"text": "She muttered aside."
}
With several speakers and no panel_id, only the first speaker drives routing (and panel speaker art). Joint lines: Lines and speakers.
If the id is not main and that panel does not exist yet, the player creates it (there is a small cap on extra panels). main is never destroyed.
Which preset (look) it uses
First match wins:
- Node field
panel(preset name) preseton apanelcommand aimed at that panel- Character
ui.default_panel_preset, if that preset exists in the theme - A preset whose name matches the panel id (e.g. id
side→panel.side) default
Unknown names fall back to default.
{
"id": "soft",
"panel": "whisper",
"talking": ["hana"],
"text": "…"
}
Character pack defaults:
"ui": {
"default_panel_id": "main",
"default_panel_preset": "whisper"
}
Sticky vs one-shot
| Surface | Effect |
|---|---|
Node panel / panel_id / panel_* policy fields | This moment only |
{ "type": "panel", "preset": "whisper" } | Stays until you change it (or "apply": "node_local") |
Pack ui.default_* | Whenever that character is first speaker and the node omits overrides |
Script sugar [panel whisper] sets the preset on main. Direction files can set panel_id and policy fields when the script is not enough — Adding direction.
Usually you need
| Want | Do |
|---|---|
Force this line to side | Node "panel_id": "side" |
| Always whisper for Hana | Pack ui.default_panel_preset: "whisper" |
| Change look for a stretch of scenes | panel command with "apply": "persistent" |
Next
How a panel looks
Lives in: themes/default.json → panel.<preset>
Presets are named looks. The story picks a preset; the theme decides size, colour, padding, name-plate docking, and how tall the box may grow before the text scrolls.
{
"panel": {
"default": {
"width": "100%",
"anchor_v": "bottom",
"min_height": 200,
"padding": { "top": 20, "left": 24, "right": 24, "bottom": 24 },
"bg": "#1e2133e0",
"corner_radius": 12
},
"whisper": {
"extends": "default",
"width": "60%",
"anchor_h": "center",
"bg": "#00000099"
},
"side_log": {
"extends": "default",
"width": "36%",
"anchor_h": "right",
"offset_x": -16,
"anchor_v": "stretch",
"max_height": "full",
"scrollbar_layout": "reserve"
}
}
}
default is the fallback preset. Other names are yours. "extends" merges another preset’s keys — Reusing settings.
Same box, two moments. The plate grows with its text, holds the name plate and portrait, and hosts choices when they appear. Continue is hidden while choices are up.
Same stage, two presets. Only the preset name changed — size, colour, and where the plate sits come from panel.<preset> in the theme.
Usually you tune
| Key | Effect |
|---|---|
width | "NN%", "100%", "stretch", or pixels |
anchor_h / anchor_v | left / center / right / stretch and top / center / bottom / stretch |
offset_x / offset_y | Nudge from the anchors (negative to move inward from right/bottom) |
min_height / max_height | Floor and ceiling — pixels, "NN%", or "full" |
padding | Inner margins |
bg | Flat plate colour |
bg_texture / bg_9slice | Nine-slice plate image (wins over flat bg) |
corner_radius | Rounded corners on the flat plate |
speaker_gap / choices_gap / choices_separation | Gaps around name and choices |
scrollbar_style | Optional element pack for the body scrollbar (else theme scrollbar.*) |
scrollbar_layout | How the scrollbar column is laid out — see below |
scroll_pin_duration | Seconds to ease to the newest line after height settles (0 = snap) |
labelplate_style_key | Which name-plate style — Name plate |
labelplate_layout | Dock the name plate to a panel edge (omit = inline above the text) |
Height and body scroll
The plate grows with text (and panel-hosted choices) up to max_height. Past that ceiling, the body text scrolls inside the plate. Name plate, panel-placed Continue / Back, and choices stay outside the scroll area.
| Height value | Meaning |
|---|---|
| number | Pixels |
"40%" | Share of window height |
"full" | Window height |
omit min_height | 160 |
omit max_height | Default share of the window (same idea as before: about 65%, with a floor from min_height) |
A sticky { "type": "panel", "max_height": … } overrides the theme preset until reset.
Scrollbar column
scrollbar_layout | Effect |
|---|---|
default (omit) | Bar appears when needed and takes width from the text when it does |
reserve | Keep a scrollbar column so wrap width stays stable; track stays hidden until there is something to scroll |
overlay | Not available yet — falls back to default |
Use reserve on logs, phone panels, or any fixed-height plate that scrolls often. Leave normal ADV boxes on default.
Panel-hosted choices (host: "panel") share the plate and shrink the body. Overlay choices sit on the screen and do not take body space. A choice list taller than the ceiling can still clip — body scroll is for dialogue text, not a separate choice scroller.
Mood for one scene
A scene can load another theme/mood file that overrides panel presets for that stretch — Theme and the dialogue box. Otherwise, prefer a second preset (whisper) or a sticky panel command.
Bind and shader
A panel command can tint or hide from flags (bind on opacity / visible) and attach a panel shader. Flag binds in depth: Reacting to flags live.
Usually you need
| Want | Do |
|---|---|
| Full-width bottom box | default with anchor_v: "bottom", width: "100%" |
| Narrow centre whisper | Preset with smaller width + anchor_h: "center" |
| Tall side log | max_height: "full" + scrollbar_layout: "reserve" — Several panels |
| Reuse most of default | "extends": "default" then override a few keys |
Next
Several panels
Lives in: theme presets + story panel_id / panel commands
Use more than one panel when two conversations should stay visible, or a side box holds a note while main advances.
Create another box
Ask for a new id on a node or command:
{ "id": "aside", "panel_id": "side", "panel": "side", "talking": ["hana"], "text": "…" }
{ "type": "panel", "panel_id": "side", "preset": "side", "show": true }
If theme panel.side exists, routing can pick that preset by matching the id name. Otherwise set "panel": "…" or pack default_panel_preset explicitly.
main always exists. Extra ids are created when first used (capped — keep the cast of boxes small).
Stacking
| Field | Effect |
|---|---|
z on a panel command | Draw order among panels; also feeds which visible panel gets input |
Higher z draws above. Keep the active reading box easy to see.
Patterns that work
| Pattern | Idea |
|---|---|
| Whisper | Same main id, different preset — no second box |
| Side commentary | panel_id: "side" + smaller preset; main keeps the primary line |
| Sticky note | Keep side visible while focus returns to main — Presence and quiet panels |
| Side / phone log | Tall preset + sticky append — see below |
Stage character places (left / center) are a different system — Places on stage. Drawing characters above/behind the box is draw_layer, not a second panel.
Side log (append + scroll)
A common second box: full-height (or tall) side panel that collects lines and scrolls inside the plate.
- Theme preset with
"max_height": "full"(or"80%") and"scrollbar_layout": "reserve"— How a panel looks - Sticky
{ "type": "panel", "panel_id": "side", "preset": "side_log", "text_mode": "append" } - Moments that write to
"panel_id": "side"(or packdefault_panel_id) - Optional
[panel panel_id side clear_append]when the thread should restart — Appending text
Keep main on normal replace dialogue while the log accumulates.
Usually you need
| Want | Do |
|---|---|
| Soft look only | One id, second preset |
| Two texts at once | Two ids + presence / readonly policy |
| Force a side box up | panel command show: true on that id |
| Scrollable log | Tall max_height + sticky text_mode: append |
Next
Presence and quiet panels
Lives in: panel policy on pack / panel command / node fields
The routed panel is the one receiving this moment’s text. Other panels are non-talking — policy decides whether they hide or stay as a dim reminder.
Policy fields
| Field | Values | Default | Effect |
|---|---|---|---|
presence_mode | talk_only, visible_with_char, always_visible | talk_only | How strongly a panel stays when it is not routed |
non_talking_mode | hide, readonly_last_text | hide | What a non-routed panel shows |
readonly_style_key | style name | — | Look for readonly text |
Practical recipe: keep a side box around without the speaker → always_visible + readonly_last_text. Plain hide when unused → leave defaults.
Today talk_only and visible_with_char do not differ for show/hide; prefer always_visible when you need “stay up.”
Where policy is set
Order (later wins for sticky layers as noted in the live rules):
- Built-in defaults
- Character pack
ui.panel_policy - Sticky fields from a
panelcommand (presence_mode,non_talking_mode, …) — beat the pack - Node fields
panel_presence_mode,panel_non_talking_mode,panel_readonly_style_key— this node only
policy_reset or full reset on a panel command clears sticky policy.
Show hold
show: true on an off-route panel sticks: policy cannot instantly hide it until show: false, reset, or policy_reset. Use that for a deliberate “pin this box.”
Node hide_panel hides the routed panel for that node only — different from non_talking_mode: "hide" on other panels.
Text modes
Default is replace — each line clears the panel. To stack lines in the same box, use append — full fields and sticky vs node: Appending text.
Usually you need
| Want | Do |
|---|---|
| Hide unused boxes | Defaults (hide) |
| Leave last side line visible | non_talking_mode: "readonly_last_text" (+ often always_visible) |
| Pin a box against policy | Off-route panel with show: true |
Next
Appending text · Speaker art on the panel
Appending text
Lives in: story moment fields and the panel command (text_mode)
By default each new moment replaces what was in the panel. Append keeps earlier text and adds this moment’s text — useful for a running log, a chat-style beat, or a sticky side panel that accumulates notes.
A moment is one click-through step of the story (also called a node in tools and data) — Words used in these docs.
Replace vs append vs plates
text_mode | Effect |
|---|---|
replace (default) | This moment’s text becomes the whole panel contents |
append | This moment’s text is added to what is already there (one label) |
plates | Each beat is a separate plate from the preset’s plates map — Theme: panel → Plates |
Per-plate transition_in / transition_duration animate the live plate chrome inside the body scroll on each new beat (see Theme: panel → Plates).
plates uses the same append_direction, append_gap_px, append_push_duration, and clear_append keys as append.
Not mid-line typing: while one moment’s text is revealing, that is still one block. Append decides what happens when the next moment writes to the same panel.
{
"id": "log_1",
"panel_id": "side",
"text_mode": "append",
"talking": ["system"],
"text": "Door unlocked."
}
For a lasting chat, prefer a sticky command so later moments keep appending:
{ "type": "panel", "panel_id": "side", "text_mode": "append", "max_height": "full" }
[panel panel_id side text_mode append]
[panel clear_append]
Direction and gaps
| Field | Values | Default | Effect |
|---|---|---|---|
append_direction | down, up | down | New text below or above the existing block |
append_gap_lines | number ≥ 0 | 0 | Blank lines between old and new |
append_gap_px | number ≥ 0 | 0 | Extra pixel gap |
When append fills past the panel height ceiling, the body scrolls and keeps the newest edge in view (down → bottom, up → top). Set the ceiling with theme max_height or a sticky panel max_height — How a panel looks.
Changing append_direction on a panel that already has a thread keeps that history and re-pins it for the new direction (newest at the bottom for down, at the top for up). It does not start a fresh log — use clear_append when you want a wipe.
Editing the script in author mode while you are mid-thread re-reads sticky panel keys (append_direction, text_mode, gaps) along the moments you already played — including the moment that opened the thread — then reorients the live thread. You do not need to be on the same moment as the sticky command. Going back after that keeps the rehydrated direction (it does not restore the pre-edit value).
Sticky vs one moment
| Surface | Effect |
|---|---|
Moment fields text_mode / append_* | This moment only |
{ "type": "panel", "text_mode": "append", … } | Stays for later moments on that panel until you change it |
A moment’s text_mode overrides a sticky panel setting for that moment only.
| Situation | What happens |
|---|---|
| That panel was replace, then this moment is append | Starts a new append log from this moment |
| That panel was sticky append, even while another panel was talking | Keeps collecting when you write to it again |
Sticky text_mode: replace, clear_append, or panel reset | Clears the log on that panel |
| One moment uses replace while sticky is still append | Shows that line alone; the log stays for later append moments |
| Only a moment field sets append (no sticky command) | That one moment appends; lasting chat needs the sticky command |
Clear the thread, keep the mode
clear_append wipes history on that panel but keeps sticky append mode, direction, preset, and policy. Use it for a fresh chat on the same box without a full reset.
[panel panel_id side clear_append]
Same-moment clear_append applies before that moment’s first paint.
Script sugar for everyday append is limited — use the direction file / moment data, or a panel command before the stretch of moments ([panel … text_mode append] / [panel clear_append] exist).
With several panels
Append is per panel id. main can stay on replace while side or a phone panel appends. A quiet append panel keeps its history until you write to it again. Presence / readonly on quiet panels is separate — Presence and quiet panels. Tall log recipe: Several panels.
Usually you need
| Want | Do |
|---|---|
| Normal dialogue | Leave text_mode off (replace) |
| One log panel | Sticky panel with "text_mode": "append" (+ max_height if it should fill the side) |
| Chat-style thread | Sticky panel with "text_mode": "plates" + preset plates / character ui.default_plate |
| Fresh thread, same mode | clear_append |
| Single appended beat | "text_mode": "append" only on that moment |
Next
Speaker art on the panel
Lives in: theme panel slots + character pack ui.panel_slots
Some panel presets reserve a slot for a picture — a bust, icon, or object that follows who is talking. That art is not a stage character place; it is chrome inside the box.
Theme slot
The preset declares a slot id (for example speaker_art) with position and size. Details grow with the theme map; copy a working preset from examples/ when you want a portrait strip.
Pack map
Point the slot at an object (or sprite) id the character owns:
"ui": {
"panel_slots": {
"speaker_art": "hana_portrait"
}
}
When this character is first speaker / routed, the panel can show that art. Theme binding "bind": "talking" on panel chrome is speaker art, not a story-flag bind — Expressions and packs · Reacting to flags live.
With joint lines
Only the first speaker in talking drives panel routing and slot art unless you set panel_id yourself — Lines and speakers.
Usually you need
| Want | Do |
|---|---|
| No portrait in the box | Leave panel_slots empty |
| Bust beside the text | Theme slot + pack panel_slots map |
| Full-body on stage | Stage character — Places on stage |
Next
Look chrome around the box: Theme and the dialogue box · Name plate
Theme and the dialogue box
Lives in: themes/default.json
Fonts, colours, and shared chrome for the whole game. The dialogue box (presets, routing, height, scroll) lives in its own part of the handbook:
→ What a panel is · How a panel looks
Where the theme lives
| Piece | In themes/default.json |
|---|---|
| Fonts and sizes | typography |
| Fallback text / empty-stage colours | colors |
| Dialogue box presets | panel.<name> — How a panel looks |
| Name-plate styles | labelplate.<name> — Name plate |
| Scrollbars | scrollbar (menus and panel body) |
| Menu / screen buttons | buttons — Buttons and actions |
Put font files under fonts/. Locale overlays can sit beside the base theme as default_<locale>.json. Pack files for choices and inputs are separate stems under themes/ — Choice and input looks.
"extends" on a panel preset merges keys — Reusing settings.
Fonts and sizes
"typography": {
"font": "Jupiteroid",
"font_menu": null,
"font_dialogue": null,
"font_label": "Jupiteroid",
"size_scale": 1.0,
"sizes": {
"speaker": 45,
"dialogue": 30,
"choice": 30,
"button": 30,
"notification": 30
}
}
| Key | Effect |
|---|---|
font | Base face under fonts/ (variants like Regular / Bold when present) |
font_dialogue | Dialogue + choices (null → font) |
font_label | Speaker name plate |
font_menu | Menus / settings buttons (null → font) |
sizes.* | Pixel sizes for speaker, dialogue, choice, button, notification |
size_scale | Multiplies all sizes.* (player text-size setting builds on this) |
Change sizes.dialogue or font, reload (F5 / F6 for look), and play a line.
Colours
"colors": {
"background_default": "#dfdcd7",
"speaker_text": "#c4693a",
"dialogue_text": "#e8e5e0",
"choice_text": "#c5cad5",
"notification_text": "#dfdcd7"
}
| Key | Effect |
|---|---|
dialogue_text | Body text fallback |
speaker_text | Name-plate text fallback |
choice_text | Choice caption fallback |
background_default | Fill when no background picture is up |
notification_text | Toast / some heading fallbacks |
Panel plate colour is usually panel.<preset>.bg, not colors — How a panel looks.
Full map
Every allowed key in one view: themes/default.json file map. Panel, buttons, and choice/input packs: Theme detail.
Mood for one stretch of story
A mood profile is another theme file (for example themes/flashback.json) that merges over default.json for a while:
{ "type": "theme_style", "profile": "flashback" }
{ "type": "theme_style", "reset": true }
Sticky until you reset or start a new game. It does not switch choice packs — use a choice style command / pack for that — Choice and input looks.
Until you add a mood file, a second panel preset or a sticky panel command is enough for a quieter box.
Usually you need
| Want | Do |
|---|---|
| Bigger dialogue text | Raise typography.sizes.dialogue |
| Warm name colour | colors.speaker_text or a labelplate style |
| Dimmer plate | panel.default.bg |
| Whole scene mood | theme_style profile file |
Next
Name plate
Lives in: themes/default.json → labelplate.*, plus character packs (display_name / ui)
The box that shows who is speaking.
What the character pack sets
| Pack key | Effect |
|---|---|
display_name | Text on the plate for that speaker (usual) |
display_name_key | Catalog key when you translate names |
ui.default_labelplate_style_key | Which name-plate style from the theme this character prefers |
ui.default_panel_id / default_panel_preset | Which dialogue panel this character prefers |
Packs: Expressions and packs. Which box shows the name: How text finds a panel. Speakers and joint labels: Lines and speakers.
Theme styles
Named looks under labelplate.<style_key>:
"labelplate": {
"default": {
"font_color": "#ffffff",
"font_size": 20,
"bg": "#00000099",
"padding_left": 12,
"padding_right": 12,
"padding_top": 6,
"padding_bottom": 6
},
"whisper": {
"font_color": "#cccccc",
"bg": "#00000066"
}
}
| Key | Effect |
|---|---|
font_color / font_size / font | Name text |
bold | Bold face when the font provides it |
bg | Flat plate colour |
bg_texture / bg_9slice | Image plate |
padding_* | Inner margins |
Resolution: start from labelplate.default (if present), then overlay labelplate.<style_key>.
Who picks the style
First match wins:
- Explicit
style_keyon alabelplatecommand / moment field - Panel preset
labelplate_style_key - Character pack
ui.default_labelplate_style_key
Inline vs docked
By default the name sits inline above the dialogue text. If the panel preset sets labelplate_layout (anchors + offsets), the plate can dock to a panel edge instead — How a panel looks.
Story override
A direction labelplate command (or moment field) can change text, style, or visibility for a beat:
{
"type": "labelplate",
"panel_id": "main",
"text": "???",
"style_key": "whisper",
"apply": "persistent"
}
| Field | Effect |
|---|---|
text / text_key | Plate body ({@auto} expands to the speaker) |
style_key | Theme style name |
visible | Show / hide |
mode | "inline" or "docked" |
apply | "persistent" (sticky) or "node_local" (this moment) |
reset | Clear sticky override |
Prefer pack display_name for everyday cast names; use the command for mystery labels and one-off looks.
Usually you need
| Want | Do |
|---|---|
| Show “Hana” | Pack "display_name": "Hana" |
| Special colour for one cast member | Theme style + ui.default_labelplate_style_key |
| Hide the plate for a beat | labelplate with "visible": false |
| “???” then reveal | Sticky text: "???", later reset or new text |
Next
Choice and input looks
Lives in: themes/<pack>.json, plus layout / story fields
How choice buttons and the name-entry field look. Story choices and input flow: Choices and memory. Layout defaults: Choice and character layout.
Choice packs
A pack is a JSON file under themes/. The name is the file stem: "style": "choice_button" → themes/choice_button.json.
Two common roles:
| Role | How you point at it | What’s inside |
|---|---|---|
| Button look | layout.choices.style, node choices.style, or sticky choice_style | Textures / flat colours / fonts for stacked text buttons |
| Group compose | choices.source / container.source / item.source | Layout + item chrome for free / sprite / overlay menus |
"style" / "source" merge rules: Reusing settings. Locale overlays: themes/<name>_<locale>.json.
Button pack (usual ADV)
{
"min_width": 220,
"padding_left": 18,
"padding_right": 18,
"padding_top": 4,
"padding_bottom": 6,
"normal_texture": "sprites/button-normal.png",
"hover_texture": "sprites/button-hover.png",
"pressed_texture": "sprites/button-down.png",
"texture_9slice": [6, 6, 6, 6],
"font_color": "#3a2a1a",
"font_hover_color": "#6b4420"
}
Flat colour keys (normal_bg, borders, …) work when textures are omitted or empty. Optional click_sfx / hover_sfx override default UI sounds for button choices.
Choice packs do not restyle screen/menu buttons (those use theme buttons) or the dialogue panel plate.
Sticky choice look
{ "type": "choice_style", "style": "choice_plain" }
Stays until you change it. Separate from theme_style mood profiles — Theme and the dialogue box.
Text input packs
Optional pack for story name-entry / text fields. No pack required — omit style and the field uses dialogue colours and flat fills.
| Piece | Keys (examples) |
|---|---|
| Field | field_font_color, field_normal_bg / textures, padding, nine-slice |
| Confirm button | Same family as choice/nav packs (normal_texture, font_color, …) |
Precedence: moment input.style → sticky input_style → layout.text_input.style → defaults.
theme_style does not restyle input chrome (same rule as choice packs).
Usually you need
| Want | Do |
|---|---|
| Default stacked choices | "style": "choice_plain" (or your pack) in layout.choices |
| Different look this scene | Sticky choice_style or node choices.style |
| Custom name field | A text-input pack + layout.text_input.style |
Next
Free choices · Title, pause and settings screens · Choice packs · Text input packs
Free choices
Most menus are a stack of buttons. Free choices place options at absolute positions — hotspots on the stage, sprite faces, inspect scenes. Same story choices object; different container.layout.
Most stories
- Keep script
>lines for ordinary menus (stack). - For clickable art: write the choice as JSON in
story.jsonwith"mode": "free", itemx/y, often"render_mode": "sprite". - Do not combine
shuffleor stackgapwithfree.
Where this lives
| Idea | Path |
|---|---|
| Group mode | choices.container.layout.mode: "stack" | "free" |
| Host | panel (dialogue slot) or overlay (full screen) |
| Faces | Item / pack style.sprite + state_visual |
| Defaults | game.json → layout.choices |
"choices": {
"show_numbers": false,
"container": {
"layout": { "mode": "free", "host": "overlay", "render_mode": "sprite" }
},
"items": [
{ "text": "Door", "target": "enter", "layout": { "x": 420, "y": 280 } },
{ "text": "Leave", "target": "NEXT", "layout": { "x": 900, "y": 600 } }
]
}
Worked pack: examples/example-05-ui-and-polish/themes/choice_yes_no.json.
Same items, same mode: free and render_mode: sprite. The only difference is host: overlay leaves them floating at their absolute x / y, while panel puts them inside the dialogue plate, which grows to fit.
Stack vs free
stack | free | |
|---|---|---|
| Placement | Vertical list | Absolute x / y |
gap / shuffle | OK | Forbidden |
| Typical look | Theme buttons | Sprites + state_visual |
Hover / pressed looks: Shared shapes → state_visual. Full keys: Choices (story). Ordinary menus: Choices and memory · Choice and input looks.
Title, pause and settings screens
Lives in: screens.json
Menus and boards around the story — title, pause, settings, credits, custom galleries. Separate from the dialogue panel and from stage sprites.
Most stories
| Screen | Role | Job |
|---|---|---|
title | modal | New Game, Continue, Settings, Quit |
pause | modal (often pauses the tree) | Resume, save/load, settings, title |
settings | modal | Volume, text speed, and similar rows |
hud | hud | Pause button (and friends) while playing |
{
"defaults": { "transition_duration": 0.3 },
"title": {
"role": "modal",
"layers": [
{ "type": "background", "order": 0 },
{
"type": "menu",
"order": 10,
"buttons": [
{ "label": "New Game", "interaction": { "hooks": { "on_click": "new_game" } } },
{ "label": "Continue", "interaction": { "hooks": { "on_click": "continue" } }, "show_if": "has_autosave" }
]
}
]
}
}
Copy a working screens.json from examples/ and change labels first — Your first ten minutes.
Full map
Every allowed key in one view: screens.json file map. Layers, blocks, and actions: Screens detail.
Roles
| Role | Effect |
|---|---|
modal (default) | Full menu; blocks the world; not persistent |
hud | Stays up during play; never pauses; input passes through by default |
scene | Under characters (special boards) |
Auto HUD id comes from game.json → session.hud_screen (default "hud"; "" turns it off) — Save, Back and story end.
Layers
Each screen is a list of layers drawn in order:
type | Job |
|---|---|
background | Full-rect plate / dimmer (often click-outside → close) |
menu | Button column (title, pause, HUD) |
content | Card of blocks (text, sliders, history, save slots, …) |
sprites | Anchored UI pictures with optional hooks |
chrome | Extra controls (Close), often docked to a content card |
Open and close
| From | How |
|---|---|
| Button / hook | interaction.hooks.on_click holds the action: new_game, screen:pause, close, … |
| Story | { "type": "screen", "value": "pause" } · "show": false to remove · "mode": "replace" |
Screens stack. Top modal usually owns input; HUDs can stay underneath. Return to title clears persistent HUD strips.
show_if on buttons (and some blocks) hides them unless a condition is true — e.g. "has_autosave".
Save / load and history
Slot UI and history logs are content blocks inside your screens. Slot count is session.save_slots — Save, Back and story end. Polished slot chrome is still early — In development.
Usually you need
| Want | Do |
|---|---|
| Change title buttons | Edit screens.json → title menu labels / actions |
| Pause from HUD | HUD button "screen:pause" — Gameplay HUD |
| Open a board mid-story | screen command with that id |
Next
Gameplay HUD · Buttons and actions
Gameplay HUD
Lives in: screens.json → screen with role: "hud"
A small bar (or corner controls) shown while the story plays — pause, backlog, and similar.
Default strip
"hud": {
"role": "hud",
"layers": [
{
"type": "menu",
"order": 10,
"layout": {
"host": "panel",
"anchor_h": "right",
"anchor_v": "top",
"offset_x": 8
},
"buttons": [
{
"label": "",
"icon": "sprites/ico-pause.png",
"interaction": { "hooks": { "on_click": "screen:pause" } }
}
]
}
]
}
| Idea | Effect |
|---|---|
role: "hud" | Persistent during play; does not pause; clicks pass through empty areas |
layout.host: "panel" | Dock relative to the dialogue panel |
host: "screen" | Dock to the window instead |
| Auto-show | session.hud_screen names which HUD id appears when play starts |
Several different HUD ids can be up at once. Story can push another strip:
{ "type": "screen", "value": "minimap" }
{ "type": "screen", "value": "minimap", "show": false }
Usually you need
| Want | Do |
|---|---|
| Pause button | HUD menu button → "screen:pause" |
| Hide HUD entirely | "hud_screen": "" in session |
| Second strip mid-story | Another role: "hud" screen + screen command |
Menus around the story: Title, pause and settings screens. Button actions: Buttons and actions.
Next
Buttons and actions
Lives in: screens.json buttons · theme buttons / named packs
What menu and HUD buttons do, and how they look.
Actions you use most
| Action | Effect |
|---|---|
new_game | Start from the beginning (optional new_game:<story_id> for multi-pack) |
continue | Resume autosave / continue slot |
screen:<id> | Open that screen |
close | Pop the top screen |
quit | Leave the game |
save / load | Open your save/load screens (when you built them) |
Hooks can use the same strings, e.g. "on_click": "screen:pause". Conditions: "show_if": "has_autosave" (and similar) hide a button until it makes sense.
Full screen structure: Title, pause and settings screens.
How buttons look
| Surface | Where |
|---|---|
| Title / pause / settings / HUD | Theme root buttons (and optional named button packs) |
| Story choice rows | Choice packs — Choice and input looks |
Typical theme keys (flat or textured): normal / hover / pressed / focus colours or textures, corner radius, padding, font colours. Named packs under themes/ can override a screen’s button look the same way choice packs do — Reusing settings.
Default click / hover sounds come from game.json → sounds unless the pack sets click_sfx / hover_sfx — Music and sounds by default.
Usually you need
| Want | Do |
|---|---|
| Title “New Game” | Menu button hook "on_click": "new_game" |
| Open pause | "on_click": "screen:pause" |
| Hide Continue until a save exists | "show_if": "has_autosave" |
| Match choice buttons | Choice pack — not theme buttons |
Next
Polish for menus: Transitions · Cursor · or back to Theme and the dialogue box
Transitions
Lives in: commands · game.json → transitions · packs / screens
How pictures and menus appear, leave, and swap. Three related systems use the word “transition” — keep them apart.
Kinds (stage and chrome)
| Kind | Effect |
|---|---|
instant | Snap |
fade | Opacity / crossfade |
slide_up / slide_down / slide_left / slide_right | Slide |
Fields: transition (or transition_in / transition_out) and duration (or transition_duration).
[bg street fade 0.5]
[hana left slide_left 0.3]
{ "type": "bg", "value": "street", "transition": "fade", "duration": 0.5 }
{ "type": "char", "name": "hana", "transition_in": "slide_left", "duration": 0.3 }
Defaults (when you omit fields)
| Surface | Default kind | Default duration |
|---|---|---|
| Background | fade | 0.5 |
| Character show/hide | instant | 0 |
| Sprite / object | instant | 0 |
| Screens | often fade from fade_default | ~0.2 in samples |
Do not fight opacity with animate on the same surface at the same time — Animation.
Scene fade (full-screen veil)
Separate from per-command fades. Timing in game.json:
"transitions": { "fade_default": 0.35 }
Used for the full-screen scene veil and as a fallback when a screen has no own transition. Not the same as the bg command’s 0.5s default.
Expression swaps (characters)
Same person, new look while already on stage → pack composition_transitions, not show/hide sugar — Expression fades.
Screens
Screen push/pop can set transition_in / transition_out / transition_duration on screens.json defaults or per screen — Title, pause and settings screens.
Panel plates (thread chrome)
Named plates under panel.<preset>.plates can set their own transition_in / transition_duration for each new beat. That motion stays inside the panel body scroll and is separate from whole-panel show/hide — Theme: panel → Plates.
Usually you need
| Want | Do |
|---|---|
| Soft place change | [bg name fade 0.5] (or omit — fade is default) |
| Soft character enter | Name a kind + duration on char |
| Faster scene veil | Lower transitions.fade_default |
| Soft expression change | Pack composition_transitions |
| Chat plate enter | Per-plate transition_in on the theme preset |
Next
Animation
Lives in: story animate / stop_animate · optional animations/presets.json
Tweens (or steps) a property on something already on stage. Show/hide fades are a different system — Transitions. Character flipbooks live in the pack — Motion and frames.
Most stories
{
"type": "animate",
"target": "char:left",
"property": "position_x",
"to": 20,
"duration": 0.3,
"relative": true
}
{ "type": "animate", "preset": "shake", "intensity": 8, "duration": 0.5 }
{ "type": "animate", "preset": "bob", "target": "sprite:fairy", "distance": 8 }
{ "type": "stop_animate", "target": "char:left" }
{ "type": "stop_animate", "all": true }
Built-in presets: shake, pulse, flash, bob. Author presets: files under animations/presets.json (locale overlays allowed).
Targets
| Target | What it drives |
|---|---|
bg | Background plate |
char:<place> | Character in that place (left, center, …) |
sprite:<id> / obj:<instance_id> | Stage prop |
panel | Dialogue panel |
screen_shader | Full-screen shader rect |
choice_item:… / ui_button:… / cursor | UI / cursor |
Common properties: opacity, position_x / position_y, scale, modulate, shader:<uniform>. Give an id when you need a precise stop_animate.
Do not animate opacity on a surface while a transition is also fading that surface.
Usually you need
| Want | Do |
|---|---|
| Nudge a character | animate on char:… + position_x / y |
| Hit feedback | "preset": "shake" |
| Soft idle on a sprite | "preset": "bob" |
| Stop | stop_animate on the target (or "all": true) |
Full map
animations/presets.json map · Animation presets · animate
Next
Shaders
Lives in: shaders/ · commands (char, bg, sprite, obj, panel, screen_shader) · plate shader (target: "bg" only)
Visual filters from .gdshader files. Attach by name (no extension), or clear with null.
On a character or prop
{
"type": "char",
"name": "hana",
"position": "left",
"shader": "drop_shadow",
"shader_params": { "blur": 2.0 }
}
| Value | Effect |
|---|---|
| key absent | Keep whatever is already on |
null | Clear |
| string | Apply shaders/<name>.gdshader |
| object | Full form — see below |
Pack files do not list shaders — the story (or a bind) does. Drive a uniform from a flag with bind and a shader:… key — Reacting to flags live.
Backgrounds: Backgrounds.
Full object
{
"name": "hotspot_glow",
"params": { "glow_radius": 45 },
"target": "full",
"pass": "subviewport",
"margin": 48
}
| Key | Effect |
|---|---|
name | File stem under shaders/ |
params | Uniforms (numbers, #hex, vectors) |
target | bg | full — allowed values differ by surface (see below) |
pass | direct or subviewport |
margin | Extra pixels so glow is not clipped (forces subviewport when > 0) |
Which forms each surface accepts, its target values, and where the effect sits is the per-surface matrix: only char / bg / stage take a bare string; sprite / obj / panel / screens need the object. Every effect is one shader per slot / instance, never per layer or part.
On a named plate (panel.<preset>.plates.<name>.shader), only target: "bg" applies — Theme: panel → Plates.
Whole-screen effect
{ "type": "screen_shader", "value": "glitch", "target": "game", "shader_params": { "noise_intensity": 0.2 } }
{ "type": "screen_shader", "value": null }
target here is the screen-shader plane (game / scene / all) — coverage map: Planes and slots. Clear with null.
Usually you need
| Want | Do |
|---|---|
| Soft shadow on a character | "shader": "drop_shadow" (+ params) |
| Clear it | "shader": null on a later char |
| Room-wide vibe | screen_shader |
| Uniform follows a flag | bind → shader:… |
Next
Cursor
Lives in: game.json → cursor · optional cursors.json
Optional custom (software) pointer, or the normal OS cursor.
Most stories
Leave cursor out (or "enabled": false) → OS cursor. Soft cursor:
"cursor": {
"enabled": true,
"default": "default",
"config": "cursors.json",
"touch_pointer": false,
"idle_hide": {
"enabled": true,
"delay": 2.5,
"fade": 0.2,
"keep_visible": ["choices", "screens"]
}
}
// cursors.json
{
"default": "default",
"profiles": {
"default": {
"kind": "image",
"image": "sprites/pointer.png",
"hotspot": [2, 2],
"scale": 1.0
}
}
}
Soft cursor follows mouse pointing. On touch it stays hidden unless touch_pointer is true.
From the story
{ "type": "cursor", "profile": "inspect" }
{ "type": "cursor", "push": "inspect" }
{ "type": "cursor", "pop": true }
{ "type": "cursor", "reset": true }
Hover hotspots can use hook actions cursor:push:… / cursor:pop. Idle-hide still works with the OS cursor (instant hide/show; fade is soft-cursor only).
Usually you need
| Want | Do |
|---|---|
| Normal pointer | Omit soft cursor |
| Custom look | enabled: true + cursors.json |
| Hide while reading | idle_hide |
| Inspect cursor on a hotspot | cursor:push / pop hooks |
Full map
cursors.json file map · cursors detail
Next
Notifications
Lives in: game.json → notifications · theme notification
Short toasts such as “Saved.” The player shows them on save/load success and failures — you rarely fire them from the story.
Copy and timing
"notifications": {
"duration": 2.0,
"labels": {
"saved": { "text": "Saved." },
"loaded": { "text": "Loaded." }
}
}
| Key | Effect |
|---|---|
duration | How long the toast stays (seconds) |
labels.<id>.text | Copy for that event (catalog keys where supported) |
Look
Skin under themes/default.json → notification (colours, padding, and similar). Global text colour fallback: colors.notification_text — Theme and the dialogue box.
Usually you need
| Want | Do |
|---|---|
| Change “Saved.” | notifications.labels.saved.text |
| Longer toast | Raise duration |
| Match your theme | Theme notification block |
Next
Keys
Lives in: optional keybindings.json
Which keys (and mouse buttons) fire which actions. Skip the file until you need different confirm keys, a custom Back key, or controller-friendly remaps.
Remap file
Arrays you list for an action replace the defaults for that action:
{
"dialogue_advance": ["Space", "Enter", "MouseLeft"],
"dialogue_back": ["Backspace"],
"pause": ["Escape"]
}
Common actions: dialogue_advance, dialogue_back, pause, plus choice numbers and menu navigation. Locale overlay: keybindings_<locale>.json.
Author builds keep debug F-keys; released player builds strip author-only shortcuts — How you work.
Back behaviour (what Back does in the story): Save, Back and story end.
Usually you need
| Want | Do |
|---|---|
| Defaults | Omit keybindings.json |
| Space to advance | Set dialogue_advance |
| Esc to pause | Set pause |
Full map
keybindings.json file map · keybindings detail
Next
Translations
Lives in: game.json → locale / i18n · overlay files · optional story/strings.json
VN-Mikro ships one base language as the unsuffixed files, then optional locale overlays and an optional strings catalog for keyed text.
Most stories
- Set
"locale"ingame.jsonto the language of your base files (the ones without a suffix). - List switcher locales under
i18n.localesif settings should offer a language row. - Add
script_en.txt(or whatever locale id) when you translate the story; keep the same moment ids. - Use
text_key/label_key/display_name_keyonly when you want a shared catalog; literals still work without one.
Where this lives
| Idea | File / surface | Path |
|---|---|---|
| Base locale + switcher list | game.json | locale, i18n.locales |
| Overlay files | Project tree | *_<locale>.* next to each base file |
| Key → string | story/strings.json (+ strings_<locale>.json) | catalog object |
| Keyed UI copy | Screens, choices, buttons, packs | text_key, label_key, suffix_key (sliders), display_name_key, title_key |
| Settings switcher row | screens.json | content { "bind": "locale", "label_key": "…" } |
{
"locale": "uk",
"title": "Мій візуальний роман",
"title_key": "game.title",
"i18n": {
"locales": [
{ "id": "uk", "label": "UK", "label_key": "screen.settings.locale_uk" },
{ "id": "en", "label": "EN", "label_key": "screen.settings.locale_en" }
]
}
}
project/
game.json
game_en.json
themes/default.json
themes/default_en.json
screens.json
screens_en.json
story/
script.txt
script_en.txt
strings.json
strings_en.json
characters/hana/default.json
characters/hana/default_en.json
{
"game.title": "Мій візуальний роман",
"screen.settings.locale_uk": "Українська",
"choice.help": "Допомогти Хані"
}
{
"settings": {
"content": {
"blocks": [
{
"bind": "locale",
"label": "Language",
"label_key": "screen.settings.language",
"style": "locale_button",
"active_style": "locale_button_active"
},
{
"type": "button",
"label": "Back",
"label_key": "screen.settings.back"
}
]
}
}
}
Catalog shape: Strings catalog. Map: game.json.
Effective locale
- Player / settings override (
set_locale), if any. - Else
game.json→locale.
That value picks which overlay files load. An overlay may contain a "locale" field, but it does not re-choose which files were loaded in that pass.
File naming
The locale id is inserted before the extension: foo.json → foo_uk.json, script.txt → script_uk.txt.
| Asset | Base | Locale file | How it combines |
|---|---|---|---|
| Game | game.json | game_<locale>.json | Deep merge |
| Theme | themes/default.json | default_<locale>.json | Deep merge |
| Style packs | themes/<name>.json | <name>_<locale>.json | Deep merge |
| Screens | screens.json | screens_<locale>.json | Screens merge (below) |
| Keybindings | keybindings.json | keybindings_<locale>.json | Deep merge |
| Cursors | cursors.json | cursors_<locale>.json | Deep merge |
| Character packs | characters/<id>/<pack>.json | <pack>_<locale>.json | Deep merge |
| Animation presets | animations/presets.json | presets_<locale>.json | Deep merge |
| Script | story/script.txt | script_<locale>.txt | Whole-file pick (no merge) |
| Story JSON | story/story.json | story_<locale>.json | Whole-file pick |
| Strings | story/strings.json | strings_<locale>.json | Catalog pick (below) |
Missing overlay → keep the base file. No crash.
Merge rules
| Family | Rule |
|---|---|
| Most JSON overlays | Deep-merge objects; arrays replace wholesale; null deletes a key |
| Screens | Same, but arrays of objects merge by id if present, else by order. Patch only what changed |
| Script / story JSON | Prefer the locale file if it exists; otherwise the base file |
Strings catalog
Optional. Used when fields carry *_key.
| Situation | Catalog used |
|---|---|
Locale equals project base locale | strings.json only |
Other locale, strings_<locale>.json exists | That file |
| Other locale, postfix missing | Fall back to strings.json if present |
| Nothing found | Empty catalog — keys miss, literals win |
Missing key → use the literal field (or the surface’s default). Games can ship with no catalog at all.
Key fields vs literals
| Surface | Key | Literal |
|---|---|---|
game.json | title_key | title |
| Screens (heading, text, links, …) | text_key | text |
| Buttons / menus / chrome | label_key | label |
| Settings sliders | suffix_key | suffix |
| Choices | text_key | text |
| Character packs | display_name_key | display_name |
Prefer key-only: set the *_key and put the copy in story/strings.json. Omit the sibling literal so the catalog is the single source. Keep a literal only as a standalone fallback when the key is missing.
Usual order: non-empty key and found in catalog → catalog value; else literal; else surface default.
check reports a keyed field that has no literal and no entry in the base catalog (i18n.missing_key).
Special cases:
- Button
label: ""stays blank even iflabel_keyis set (icon-only). - Labelplate prefers a non-empty literal
text/prefix/suffixbefore keys. - Character display name: catalog →
display_name→ capitalized folder name.
Settings locale switcher
A settings { "bind": "locale" } block expands into the locales in i18n.locales as chips. The active one uses active_style (default locale_button_active) and is not clickable. Locales not listed in i18n.locales are rejected by set_locale.
Mid-session language switch
When the player picks another locale:
- Config and theme reload for the new overlays.
- Story reloads (
script_<locale>/story_<locale>+ strings). - Play restores the same node id and flags without replaying commands.
- If that node id is missing in the new story, the previous locale is restored and a
locale_unavailablenotification shows. - On success: backlog relocalizes and open screens rebuild.
Keep node ids aligned across locale scripts if players can switch mid-game.
Usually you need
| Want | Do |
|---|---|
| One language | Set locale; skip overlays |
| Second language | Overlay files + i18n.locales |
| Shared UI strings | story/strings.json + *_key fields |
Related
Strings catalog · Title, pause and settings screens · Theme and the dialogue box · Pictures, sound, and folders
Title, icon, splash
Lives in: game.json
How the game presents itself before the first line — window name, shell icon, and boot splash. In-game panel and title-menu chrome are separate — Theme and the dialogue box · Title, pause and settings screens.
Title, version, authors, and window size: Name, window, language.
Checklist
| Set this | Where | What players see |
|---|---|---|
| Title | title (or title_key) | Window title; web page title; build folder slug; desktop app names |
| Version | version | Build folder (…-v1.0); store / changelog label |
| Authors | authors | Windows EXE company field; optional credits |
| Icon | icon | Desktop / window / web favicon / Android launcher |
| Splash | splash | Boot / loading look on web, Android, and delivered desktop builds |
| Android package | android.package_id | Required for Android builds — Platforms |
{
"title": "My Novel",
"version": "0.1",
"icon": "ui/icon.png",
"splash": {
"bg_color": "#1a1a2e"
}
}
Icon
"icon": "ui/icon.png"
Prefer a square PNG (≥256 px). One path; Build stamps each platform. Empty icon keeps the VN-Mikro player mark.
| Target | Result |
|---|---|
| Linux | Icons + desktop entry beside the player |
| Windows | Stamps the .exe (Explorer icon + version strings from title / authors) |
| macOS | .icns in the .app; Finder name from title |
| Web | Favicon / apple-touch icons next to index.html |
| Android | Launcher icons inside the APK / AAB |
If Windows stamp tools are missing on your machine (Wine / rcedit), Build can still finish with the default icon — Build and hand off.
Splash
Shared colours and logo at the root; optional web / android / desktop blocks override what that platform needs. Omitted colours are filled from your theme at Build time.
"splash": {
"bg_color": "#1a1a2e",
"logo": "sprites/logo.png",
"logo_max_width": 300,
"progress_bar": {
"width": "50%",
"height": 12,
"background": "#343a52",
"fill": "#7a8a9e"
},
"desktop": { "min_seconds": 0.6 }
}
| Key | Effect |
|---|---|
bg_color | Solid splash fill (else theme panel / background colour) |
logo | Centered brand mark |
background | Optional full-bleed art under the logo |
logo_offset_x / y | Nudge from centre |
progress_bar | Loading bar (web + Android overlay; not desktop) |
desktop.min_seconds | Minimum brand hold on desktop production builds (0 = as soon as ready) |
Platform nested blocks merge key-by-key over the root — Reusing settings.
Usually you need
| Want | Do |
|---|---|
| Named build | Set title + version |
| Your mark on the EXE / app | Set icon |
| Matching boot flash | Set splash.bg_color (and optional logo) |
Next
Platforms
Lives in: game.json · Build tab targets
What each Build target needs. How to run a build: Build and hand off. Identity (title, icon, splash): Title, icon, splash.
Desktop (Windows, macOS, Linux)
| Need | Notes |
|---|---|
title / version / optional icon | Names the output folder and stamps the binary / app |
| Output | Folder with player + game data side by side |
Windows EXE company string comes from authors. On Linux/macOS hosts, Windows icon stamping may need Wine so the kit’s stamp tool can run — Build still succeeds without it.
Web
| Need | Notes |
|---|---|
| Same identity keys | Page title + favicons from title / icon |
| Splash | HTML loading shell uses splash (+ optional web overrides) |
| Mode | Always packed (pck) |
Zip the build folder for itch / itch-like hosts; leave out BUILD_INFO.json.
Android
"android": {
"package_id": "com.studio.mygame",
"version_code": 1
}
| Key | Effect |
|---|---|
package_id | Reverse-DNS application id. Required for Android Build. Never change after the first Play upload for that listing |
version_code | Integer for Play / versionCode — raise for every Play upload (Build does not auto-bump) |
Root title, version, and icon become the store label, version name, and launcher icon.
| Target | Signing |
|---|---|
| Android APK (itch / sideload) | Optional in Build prefs; empty → debug-signed installable |
| Android Play (AAB) | Release keystore required in Build prefs (machine-local, not in game.json) |
Needs JDK 17 or 21 with java on PATH. The Build tab lists what is missing. Stamp tools ship in the author kit — you do not install an Android SDK.
One output file (.apk or .aab) with game data inside.
Usually you need
| Want | Do |
|---|---|
| Desktop zip | Build that OS target; ship the folder minus BUILD_INFO.json |
| Web upload | Build web; set splash colours that match the game |
| Play Store | android.package_id + rising version_code + Play AAB + keystore |
Next
Build and hand off
Lives in: author player → F12 → Build
Turn the open project into a folder (or install file) you can give to players. Player builds do not include the author overlay or F1–F12 author shortcuts.
Overlay tabs and prefs: Author tools.
Before you build
- Set identity in
game.json—title,version, optionalicon/splash/android— Title, icon, splash · Platforms - Press F12 → Validation and fix errors
- Play through critical paths once
Build tab
- Choose Target (self / linux / windows / macos / web / Android APK / Android Play — whatever your author kit ships).
- Choose Mode:
pck(default) orfolderwhere offered. Web and Android always usepck(Android embeds the pack inside the APK/AAB). - Set an output folder (absolute path), or Use CWD / Pick….
- Leave Filter unused assets and Run prebuild checks on unless you have a reason not to.
- For Android Play (AAB): set signing fields (release keystore). For Android APK (itch/sideload), signing can stay empty — Build uses a debug keystore automatically. Install JDK 17 or 21 so
javaworks on PATH; the Build tab warns if it is missing. - Press Run Build.
- Use Open Folder, Copy Path, or Copy Log as needed.
Output lands in {game-slug}-{platform}-v{version}/ under the path you chose (title and version come from game.json). That folder is flat: player binary + game data side by side. Building again to the same name replaces the folder, so you never end up with both data.pck and assets/ from two modes.
Prefs are remembered in vn-mikro-debug.cfg next to the player.
What to give players
Give players the build folder contents except BUILD_INFO.json. That file is an author working receipt (kit version, mode, paths) — leave it out of zips, itch uploads, and store packages.
| In the build folder | Ship to players? |
|---|---|
Player binary (.exe / binary / {slug}.app / web shell) | Yes — named from game.json → title (slug). macOS Finder label uses the title |
{slug}.apk or {slug}.aab (Android) | Yes — one file; game data is already inside |
data.pck or assets/ | Yes (desktop/web; not left beside Android APK/AAB after a successful stamp) |
Linux icons/ + .desktop (Linux builds) | Yes |
| Leftover macOS player zip / extensionless zip copy | No — Build removes it after expanding {slug}.app |
BUILD_INFO.json | No — author-only working file |
| Mode | Result |
|---|---|
| pck (default) | Player binary + a packed data.pck (game data in one file) |
| folder | Player binary + an assets/ tree (readable files next to the exe) |
Web: copies the kit’s web shell into the build folder and always writes sibling data.pck. Serve that folder over HTTP (not file://). Splash / favicon: Title, icon, splash. Omit BUILD_INFO.json when you upload the web folder too.
Android: needs game.json → android (package_id, optional version_code) and JDK 17 or 21 with java on PATH — Platforms. APK (itch/direct) can omit Build signing prefs (debug-signed). Play AAB needs your release keystore in prefs. Two targets write separate folders (…-android-v… and …-android-play-v…). Keystore passwords stay in local prefs — do not commit them with the story.
Filter and checks
Prebuild checks fail early if there is no story (script.txt or story.json) or other required pieces are missing.
Filter unused assets (on by default) keeps known runtime folders (themes/, story/, characters/, audio/, backgrounds/, sprites/, animations/, shaders/, ui/, fonts/, extensions/) plus root config JSON and any folder that holds a declared native extension entry. It is not a full “referenced file” scan — turn the filter off if something you need is still getting skipped.
A successful build is a player package: no debug overlay, no project picker, no author F-keys. Players run the binary; they do not edit your source folder.
Usually you need
| Want | Do |
|---|---|
| Desktop demo | Build your OS → zip folder minus BUILD_INFO.json |
| itch web | Build web |
| Sideload Android | Build Android APK (JDK on PATH) |
| Play Console | Build Android Play + keystore + bump version_code |
Related
Author tools · Title, icon, splash · Platforms · Native extensions · Runtime version
Runtime version
This handbook matches runtime 0.1.0.
That number is the VN-Mikro player / author kit version — the app you write and build with. It is not the version of your novel.
Runtime vs game version
| Version | Where it lives | Who cares |
|---|---|---|
| Runtime | Author kit MANIFEST.json → runtime_version; debug overlay Meta line (vn-mikro …) | Matching kit, players, and this handbook |
| Game | Your project game.json → version | Stores, changelogs, Build output folder names |
Mixing kits from different runtime versions can break Build targets or behaviour. Prefer one kit; check Meta or MANIFEST.json if something looks off.
Native extensions may set runtime_min so an older player skips a pack that needs a newer kit — extensions.
Where to see it
- Debug overlay (F12) → Meta — starts with
vn-mikroand the runtime version. - Author kit root —
MANIFEST.jsonfieldruntime_version. - This sidebar — the top link shows the same number when the handbook was built for that kit.
Related: Author tools · Build and hand off · Name, window, language (version).
Native extensions
Advanced. Most projects should stay on script.txt / JSON. Use a native extension only when you need custom systems the declarative surface does not cover (inventory, custom parallax math, a one-off minigame).
A native extension is a GDScript entry file your project declares in game.json. The player compiles and loads it at startup. The runtime is built with Godot; this is the one handbook place that expects you to write GDScript against a small host API.
This page is the runtime integration map — how your Node lives inside the player. Method tables live on extensions (game.json).
When to use
| Prefer | When |
|---|---|
| Script / JSON / screens | Dialogue, flags, layouts, most FX (shake, flash, shaders) |
| First-class runtime feature (later) | Patterns many games would copy the same way |
| Native extension | Game-specific logic you will maintain yourself |
Mental model
Your entry Node → parented under reserved "Extensions" as ext_<id>
Visuals → children of host.get_plane("…") CanvasLayers
Story → host (flags, commands, jump, signals, blocker)
Screens / toast → host.push_screen / notify, or authored screens.json
Godot power → only on nodes you create (process, input, particles, …)
- Host is the stable contract. Prefer it over digging into private player nodes.
- Full engine power on your subtree is intentional (unsandboxed). That does not mean every scene path is supported.
- There is no long-term stability promise beyond the documented host API. Pin
runtime_minto the player version you tested.
Trust model
- Unsandboxed: files, network, process — anything the OS user can do.
- Author mode: toast after title listing loaded id(s) (“unrestricted code”).
- Production / shipped: silent load; pack is part of the product you publish.
- Author Refresh (F5) reloads extensions in author mode. Production still loads once at boot.
Example: examples/example-06-native-extension/.
Lifecycle
| Moment | What happens |
|---|---|
| Cold boot | After core wiring: load every extensions[] entry; call vn_extension_setup(host) once per success |
| Title | Phase "title"; modal title is up; your nodes already exist unless you hid them |
| Play | Phase "playing"; story flags/commands/signals fire |
| Session end → title | Input blocker cleared; story reloads; your extension Node is not torn down — reset on connect_story_ended if needed |
| F5 (author) | Free all Extensions children; unregister ext.* saves and ext_* command handlers; load_all again; toast |
| Production | No F5 reload — edit .gd → rebuild / restart the player |
There is no teardown / unload API beyond F5’s full reload. Design for long-lived nodes.
Where visuals and UI sit
Planes are fixed. You choose which plane your content attaches to via host.get_plane(id).
| Plane | Typical use from an extension |
|---|---|
bg | Parallax / under-character FX |
overlay | Over panel, under HUD/modals |
hud | Always-visible chrome (still under modals) |
notify | Custom toasts / banners above most UI |
modal | Draw over title/pause (use carefully — don’t steal all input) |
cursor / transition | Rare; easy to break chrome |
Full plane list for get_plane: extensions (game.json). Authored menus: Title, pause and settings screens + host.push_screen(id).
Title and pause are modal screens (high layer). Children on overlay stay under them. To appear above title, parent to modal or notify, or use host.notify.
Pause vs dialogue blocker
Two different gates:
| Mechanism | What it does | Extension tip |
|---|---|---|
Screen pauses_tree (pause menu) | Tree pauses — nodes with default process mode freeze | Set process_mode = Node.PROCESS_MODE_ALWAYS on your extension root and any child that must animate or take input while paused |
host.set_input_blocker(true) | Blocks dialogue advance and choice confirm only | UI screens and pause still work; call host.resume() or set_input_blocker(false) to clear |
host.resume() clears the input blocker only — not story history, not pause, not a return from jump.
Input
- Dialogue UI often consumes clicks for advance. Prefer
_input(not only_unhandled_input) when you need clicks during play — see ex06click_particles. - While a modal screen is up, world input is usually blocked; your node still needs a process mode that runs if you want events under pause.
- Gate by phase when FX should not run on title:
host.get_phase()→"title"|"playing"|"exit_commands"|"story_ending".
Story integration
| Need | Use |
|---|---|
| React to nodes / flags / end | connect_node_entered / connect_flag_changed / connect_story_ended |
| Private state in saves | register_save → stored as ext.<id>.<key>; restore runs after core |
| Drive from script | register_command("verb", …) → type ext_<id>_verb in Mode A or JSON |
| Rich command fields | Prefer JSON / Mode B objects; Mode A mainly gives "args" plus key=value tokens |
| Soft lock the line | set_input_blocker(true) then jump / resume when done |
| Toast | notify(message) (draws above modal) |
Flags are shared with the story — prefer names like ext_<id>_score. Check allowlists ext_* only for ids declared in game.json.
Recipes
Always-on FX (runs under pause)
func vn_extension_setup(host) -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
var plane := host.get_plane("overlay")
var fx := CPUParticles2D.new()
fx.process_mode = Node.PROCESS_MODE_ALWAYS
plane.add_child(fx)
Visible over title
Parent to host.get_plane("modal") or "notify", or call host.notify("…"). Still set PROCESS_MODE_ALWAYS if the title path ever pauses the tree.
Minigame beat
host.set_input_blocker(true)- Show your UI on a plane (or
push_screenan authored screen id) - On success:
host.set_flag(…),host.jump("next_node"),host.resume()
Title-only decoration
func _process(_dt: float) -> void:
visible = (host.get_phase() == "title")
(Keep a host reference from setup; or poll only when you care.)
Command-driven effect
host.register_command("burst", func(cmd: Dictionary) -> void:
# cmd.type == "ext_my_id_burst"; Mode A may set cmd.args
pass
)
Story: [ext_my_id_burst power=2] or { "type": "ext_my_id_burst", "power": 2 }.
Do / don’t
| Do | Don’t |
|---|---|
Parent visuals via get_plane | Assume deep paths into private player UI are stable |
| Use Host for story, save, screens, blocker | Expect Host to wrap every engine API |
Reset on connect_story_ended | Assume session end frees your extension |
Author new menus in screens.json | Clone title by digging screen-manager internals |
Namespace flags ext_<id>_* | Stomp story flags by accident |
Pin runtime_min | Rely on undocumented private methods |
Failure modes (short)
Missing file, bad GDScript, missing vn_extension_setup, duplicate id, or runtime_min too new → that entry is skipped; siblings still load. Details: extensions API.
Related
extensions (game.json) · Screens · Save, Back and story end · Build and hand off · game.json map
How to use the reference
The guide parts teach one idea at a time. The reference is for when you already know what you want and need the exact name.
| You want to… | Open |
|---|---|
| See everything a file can hold, at a glance | File maps |
| Find something by name | Everything A–Z — use your browser’s search (Ctrl+F / ⌘F) |
| Find where the full option tables live | Detail pages |
| Understand a shape that appears in many places | Shared shapes |
| Look up a word these docs use | Words used in these docs |
You never need to read the reference top to bottom.
Guide vs map vs detail: How these docs work.
Check your project
Do not rely on memory (or an AI guessing) for whether a project is valid. With the author kit:
- Open the project in the author player.
- F12 → Validation — broken jumps, unreachable branches, missing assets for the active story pack.
- Fix what the player reports when it loads the project; a kit may also ship a separate check tool.
Fix what Validation reports, then Build. Overlay details: Author tools.
File maps
A file map shows a whole settings file as it would look written out. Overview links jump to sections. Each section has a plain key tree, a Keys list (type, If omitted, values, note), and one Learn more link to the guide.
The tree shows an example write. If omitted is what the player uses when you leave that key out. You only write the keys you want to change.
| File | What it holds | Map |
|---|---|---|
game.json | Name, window, text speed, sound, saving, layout, splash | game.json |
story/story.json | Moments as data (array of nodes) | story/story.json |
screens.json | Screens you name (title, pause, …) plus optional defaults | screens.json |
themes/default.json | Fonts, colours, dialogue box, buttons, controls | themes/default.json |
characters/<id>/<pack>.json | Layered character packs | characters |
cursors.json | Pointer pictures | cursors.json |
keybindings.json | Keys | keybindings.json |
sprites/<id>.obj.json | Reusable objects | objects |
animations/presets.json | Your own animation presets | animations |
game.json — file map
Every game has one game.json in the project folder. Most games set only title, window and a few session keys — everything else has a sensible default.
The whole file
Keys with { … } open below. Linked names jump to that section. Grey notes say what each key does. Under each section, Keys lists type, If omitted, allowed values, and a short note.
{
"title": "My Novel", // game name
"title_key": "game_title", // translated game name
"version": "1.0.0", // your version label
"authors": ["Your Studio"], // credits / company
"icon": "icon.png", // app icon path
"window": { … }, // base screen size; the picture scales to any window
"locale": "en", // starting language
"i18n": { … }, // languages offered in Settings
"typing": { … }, // how dialogue text appears
"sounds": { … }, // title music, voice beeps, button sounds
"transitions": { … }, // default fade between scenes and screens
"notifications": { … }, // short messages like Saved
"session": { … }, // saving, Back, start and end of the story
"layout": { … }, // where choices, name entry and characters go on screen
"cursor": { … }, // custom mouse pointer
"splash": { … }, // boot / loading look before the title on web, Android, and delivered desktop. Draw order: bg_color → background → logo → progress bar. Root keys are shared; web / android / desktop only override that platform
"android": { … }, // Android store identity for builds
"stories": [ { … } ], // several story packs in one game (early)
"extensions": [ { … } ] // advanced: your own code packs
}
Root keys
Top-level keys that are not objects.
title stringstringauthors arraylist of stringsicon pathproject pathLearn more: title · title_key · icon
window
Base screen size; the picture scales to any window.
{
"window": { // base screen size; the picture scales to any window
"width": 1280, // design width (px)
"height": 720 // design height (px)
}
}
Keys
width int1280pixels ≥ 1height int720pixels ≥ 1Learn more: window
i18n
Languages offered in Settings.
{
"i18n": { // languages offered in Settings
"locales": [ { // one entry per language
"id": "en", // language code
"label": "English", // name in Settings
"label_key": "lang_en" // translated language name
} ]
}
}
Keys
locales[].id stringen, uk). Required in each entry.locales[].label stringidlocales[].label_key stringlabellabel.Learn more: i18n
typing
How dialogue text appears.
{
"typing": { // how dialogue text appears
"cps": 30, // letters per second
"cps_fast": -1, // speed while advance held
"delay": 0.07, // seconds per letter
"delay_fast": 0.01, // fast seconds per letter
"instant": false, // always show whole lines
"speed_scale": 1.0, // overall speed multiplier
"auto_advance_default": 0.0, // auto-advance delay
"reveal": "typewriter", // how letters appear
"pending_alpha": 0.0, // untyped letter opacity
"reveal_soft": { // settings for the soft reveal
"fade_chars": 2.5 // soft fade width
},
"reveal_pop": { // settings for the pop reveal
"pop_scale": 1.2, // pop start scale
"fade_chars": 1.5 // pop fade width
},
"reveal_rise": { // settings for the rise reveal
"rise_px": 6.0, // rise distance
"fade_chars": 1.5 // rise fade width
},
"caret": { // a blinking mark where typing is
"enabled": true, // show typing caret
"blink": 0.5, // blink period
"glyph": "▋", // caret character
"color": "#ffffff", // caret colour
"offset_x": 0, // nudge right
"offset_y": 0, // nudge down
"height": "line", // caret height
"scale": 1.0, // extra size
"texture": "ui/caret.png" // caret picture
},
"beep": { // voice beep while text types
"enabled": true, // play typing beep
"sample": "typing_beep", // beep sample id
"volume": 0.5, // beep volume
"pitch_min": 0.9, // beep pitch low
"pitch_max": 1.1 // beep pitch high
},
"start_sfx": { // sound when a line starts typing
"enabled": true, // play start sound
"sample": "msg_in", // start sample id
"volume": 1.0 // start volume
},
"end_sfx": { // sound when a line finishes typing
"enabled": true, // play end sound
"sample": "msg_done", // end sample id
"volume": 1.0 // end volume
}
}
}
Keys
cps numberdelay≥ 0 · 0 = instant0 = whole line at once. Omit to use delay instead.cps_fast number-1-1 means use delay_fast.delay number0.07cps is omitted.delay_fast number0.01cps_fast is unused.instant boolfalsetrue · falsecps).speed_scale number1.0auto_advance_default number0.00 = wait for click.reveal enumtypewritertypewriter · soft · fade_line · pop · risepending_alpha number0.00–10–1). Ignored for fade_line.reveal_soft.fade_chars number2.5soft reveal.reveal_pop.pop_scale number1.2pop reveal.reveal_pop.fade_chars number1.5pop reveal.reveal_rise.rise_px number6.0rise reveal.reveal_rise.fade_chars number1.5rise reveal.caret.enabled booltruetrue · falsecaret.blink number0.50 = solid (no blink).caret.glyph string▋texture is set.caret.color color#ffffff#RRGGBBcaret.offset_x number0caret.offset_y number0caret.height string or numberlineline · pixels"line" (match line height) or a pixel number.caret.scale number1.0caret.texture pathglyph insteadproject pathglyph. Path under the project.beep.enabled booltruetrue · falsebeep.sample stringtyping_beepaudio idaudio/sfx/ played while text types.beep.volume number0.50–10–1), multiplied by master × SFX.beep.pitch_min number0.9beep.pitch_max number1.1start_sfx.enabled booltruetrue · falsestart_sfx.sample stringaudio idaudio/sfx/ played once when the line begins (not on Back/load).start_sfx.volume number1.00–10–1), multiplied by master × SFX. Not gated by Typing Sound.end_sfx.enabled booltruetrue · falseend_sfx.sample stringaudio idaudio/sfx/ played once when the whole line finishes (not each pause, not on Back/load).end_sfx.volume number1.00–10–1), multiplied by master × SFX. Not gated by Typing Sound.Learn more: typing
sounds
Title music, voice beeps, button sounds.
{
"sounds": { // title music, voice beeps, button sounds
"main_bgm": "title_theme", // title music id
"main_bgm_volume": 1.0, // title music volume
"main_bgm_fade_in": 1.5, // fade-in seconds
"main_bgm_fade_in_from": 0.0, // fade-in from volume
"main_bgm_fade_out": 1.5, // fade-out seconds
"main_bgm_fade_out_to": 0.0, // fade-out to volume
"button_click": "click", // button click sound
"button_hover": "hover", // button hover sound
"button_volume": 1.0, // button sound volume
"defaults": { // Settings values on first launch
"master": 1.0, // first-launch master
"bgm": 1.0, // first-launch music
"sfx": 1.0, // first-launch SFX
"typing_enabled": true // first-launch beep on/off
}
}
}
Keys
main_bgm stringaudio idmain_bgm_volume number1.00–10–1).main_bgm_fade_in number1.5main_bgm_fade_in_from number0.00–10–1).main_bgm_fade_out number1.5main_bgm_fade_out_to number0.00–10–1).button_click stringaudio idbutton_hover stringaudio idbutton_volume number1.00–10–1).defaults.master number1.00–10–1).defaults.bgm number1.00–10–1).defaults.sfx number1.00–10–1).defaults.typing_enabled booltruetrue · falseLearn more: sounds
transitions
Default fade between scenes and screens.
{
"transitions": { // default fade between scenes and screens
"fade_default": 0.35 // default fade seconds
}
}
Keys
fade_default number0.35Learn more: transitions
notifications
Short messages like Saved.
{
"notifications": { // short messages like Saved
"duration": 2.0, // toast hold time
"labels": { // text per message: saved · loaded · save_incompatible
"saved": { // any name you choose
"text": "Saved!" // message text
}
}
}
}
Keys
duration number2.0labels.*.text stringsaved, loaded, save_incompatible, …).Learn more: notifications
session
Saving, Back, start and end of the story.
{
"session": { // saving, Back, start and end of the story
"boot": "title", // cold start screen
"allow_back": false, // enable Back
"back_retype": false, // retype when going Back
"back_limit": 0, // Back step limit
"save_slots": 9, // manual save slots
"hud_screen": "hud", // in-game HUD id
"post_story_screen": "credits", // screen after last line
"post_story_return_to_title": true, // return to title after end
"default_story": "main" // New Game story pack
}
}
Keys
boot enumtitletitle · new_game"title" shows the title screen; "new_game" skips it and starts play.allow_back boolfalsetrue · falseback_retype boolfalsetrue · falseback_limit int00 = unlimited · ≥ 10 = no limit.save_slots int91–99hud_screen stringhudscreen id · """" turns the HUD off.post_story_screen string""screen id · ""post_story_return_to_title booltruetrue · falsedefault_story stringmainstory idLearn more: session
layout
Where choices, name entry and characters go on screen.
{
"layout": { // where choices, name entry and characters go on screen
"choices": { // choice menu defaults
"show_numbers": true, // number choices
"shuffle": false, // shuffle choice order
"gap": 10, // space between choices
"style": "choice_button", // choice button pack
"container": {}, // menu placement box
"item": {} // per-choice defaults
},
"text_input": { // name-entry defaults
"style": "input_default", // input look pack
"source": "input_extra", // extra input look
"container": {}, // field placement
"field": {}, // field chrome
"confirm": {"label": "OK"} // confirm button
},
"characters": { // places for characters on stage
"mode": "adaptive", // place layout engine
"base_texture_size": 600, // art height basis
"active_profile": "default", // active arrangement
"adaptive": { // an even row of places
"slot_count": 3, // how many places
"slot_width": 400, // place width
"slot_height": 720, // place height
"side_margin": 20, // side inset
"slot_gap": null, // gap between places
"snap": "bottom", // vertical snap
"margin": 0, // edge margin
"auto_selection_policy": "nearest_center_out", // free-place pick order
"alias_strategy": "canonical", // how places are named
"aliases": ["left", "center", "right"], // custom place names
"z_pattern": "center_front" // draw-order pattern
},
"explicit": { // your own arrangements (same as profiles below)
"active_profile": "default", // active explicit profile
"profiles": {} // named arrangements
},
"profiles": { // arrangements by name
"closeup": { // any name you choose
"aliases": {}, // extra names for places
"auto_selection_policy": "nearest_center_out", // free-place pick order
"transition": {}, // move between arrangements
"slots": [ { // the places
"id": "left", // place id
"x": 320, // x position
"y": 720, // y position
"width": 400, // place width
"height": 720, // place height
"z": 0, // draw order
"order": 0, // auto-pick order
"draw_layer": "behind_panel", // behind or above panel
"modulate": "#ffffff", // tint
"scale": 1.0 // size multiplier
} ]
}
}
}
}
}
Keys
choices.show_numbers booltruetrue · false"1.", "2.", …choices.shuffle boolfalsetrue · falsechoices.gap number-1 (use theme spacing)-1 means use theme spacing.choices.style stringpack nametext_input.style stringpack nametext_input.source stringpack nametext_input.field objecttext_input.confirm object{ "label": "OK" }).characters.mode enumadaptiveadaptive · explicit"adaptive" = even row; "explicit" = your named profiles.characters.base_texture_size number600pixels ≥ 1characters.active_profile stringdefaultcharacters.adaptive.slot_count int32–…characters.adaptive.slot_width numberbase_texture_sizecharacters.adaptive.slot_height numberslot_width, or base_texture_sizecharacters.adaptive.side_margin number20characters.adaptive.slot_gap number or nullnull — auto gap to fit the windownull · pixelsnull = auto gap to fit the window. A number sets an exact gap (negative overlaps).characters.adaptive.snap enumbottombottom · topcharacters.adaptive.margin number0characters.adaptive.auto_selection_policy stringnearest_center_outnearest_center_out · policy namecharacters.adaptive.alias_strategy enumcanonicalcanonical · indexed · customcharacters.adaptive.aliases arrayleft/center/right for 3 slots)list of namescustom.characters.adaptive.z_pattern stringcenter_frontcenter_front · pattern namecenter_front).characters.explicit.active_profile stringdefaultexplicit.profiles is active.characters.explicit.profiles objectcharacters.profiles.*.aliases objectcharacters.profiles.*.auto_selection_policy stringnearest_center_out{"kind":"instant","duration":0}characters.profiles.*.slots[].id stringcharacters.profiles.*.slots[].x numbercharacters.profiles.*.slots[].y numbercharacters.profiles.*.slots[].width numbercharacters.profiles.*.slots[].height numbercharacters.profiles.*.slots[].z number0characters.profiles.*.slots[].order number0characters.profiles.*.slots[].draw_layer enumbehind_panelbehind_panel · above_panelcharacters.profiles.*.slots[].modulate color#ffffff#RRGGBBcharacters.profiles.*.slots[].scale number1.0Learn more: layout
cursor
Custom mouse pointer.
{
"cursor": { // custom mouse pointer
"enabled": true, // use custom pointer
"default": "arrow", // starting pointer profile
"config": "cursors.json", // pointer catalog file
"touch_pointer": false, // show on touch
"idle_hide": { // hide the pointer while reading
"enabled": true, // hide while idle
"delay": 2.5, // idle seconds
"fade": 0, // fade-out seconds
"keep_visible": ["choices", "screens"] // never hide during…
}
}
}
Keys
enabled boolfalsetrue · falsedefault string"default", or the default in cursors.jsoncursors.json to start with.config stringcursors.jsonproject pathtouch_pointer boolfalsetrue · falseidle_hide.enabled boolfalse unless you enable idle-hidetrue · falseidle_hide.delay number2.50.5–30 secondsidle_hide.fade number0idle_hide.keep_visible array["choices","screens"] when idle_hide is setchoices · screens · hotspotsLearn more: cursor
splash
Boot / loading look before the title on web, Android, and delivered desktop. Draw order: bg_color → background → logo → progress bar. Root keys are shared; web / android / desktop only override that platform.
{
"splash": { // boot / loading look before the title on web, Android, and delivered desktop. Draw order: bg_color → background → logo → progress bar. Root keys are shared; web / android / desktop only override that platform
"bg_color": "#1a1a2e", // solid fill behind everything
"background": "ui/splash_bg.png", // full-bleed picture under the logo
"logo": "sprites/logo.png", // centred brand mark (prefer PNG)
"logo_max_width": 300, // max logo width
"logo_offset_x": 0, // nudge logo right (+)
"logo_offset_y": 0, // nudge logo down (+)
"boot": { // logo drawing on the web shell
"fullsize": false, // stretch logo to fill (web)
"use_filter": true // smooth logo scaling (web)
},
"progress_bar": { // loading bar (web + Android; not desktop)
"background": "#2a3060", // track (empty bar) colour
"fill": "#6a75b0", // progress fill colour
"width": "50%", // bar width (px or %)
"height": 6, // bar height
"border_radius": 3, // bar corner radius
"bottom_percent": 10 // distance from bottom (%)
},
"web": { // web-only overrides (merged over root; any splash key may live here)
"notice": { // error plate if the web pack fails
"background": "#5b3943", // error plate fill (web)
"border_color": "#9b3943", // error plate border (web)
"text_color": "#e0e0e0" // error plate text (web)
}
},
"android": {}, // Android-only overrides
"desktop": { // delivered desktop builds only
"min_seconds": 0.6 // min brand hold (desktop builds)
}
}
}
Keys
bg_color colorpanel.default.bg, else colors.background_default; no theme → kit #000000#RRGGBB · nullbackground pathbg_color onlyproject path · nulllogo pathnull also hides it)project path · nullicon is used for the OS splash icon.logo_max_width number or size300; in-engine overlays leave width unconstrainedpx · CSS size"40%".logo_offset_x number0logo_offset_y number0boot.fullsize boolfalseboot.use_filter booltruefalse = sharp / pixel art.progress_bar.background colorpanel bg from theme; no theme → kit #2a3060#RRGGBBprogress_bar.fill colorcolors.speaker_text, else choice_text; no theme → kit #6a75b0#RRGGBBprogress_bar.width number or size"50%"px · CSS sizeprogress_bar.height number or size6progress_bar.border_radius number or size3progress_bar.bottom_percent number100–100 % of screen heightweb.notice.background color#5b3943#RRGGBBweb.notice.border_color color#9b3943#RRGGBBweb.notice.text_color colornotification_text / dialogue_text; no theme → kit #e0e0e0#RRGGBBandroid objectlogo_max_width).desktop.min_seconds number0.60–… seconds0 = dismiss when ready. Not used on web/Android. Author Open does not show this overlay.Learn more: splash
android
Android store identity for builds.
{
"android": { // Android store identity for builds
"package_id": "com.studio.mygame", // Android app id
"version_code": 1 // Play versionCode
}
}
Keys
package_id stringreverse-DNS idcom.studio.game). Required for Android builds. Never change after first Play upload.version_code int11–…Learn more: android
stories
Several story packs in one game (early).
{
"stories": [ { // several story packs in one game (early)
"id": "main", // story pack id
"title": "Chapter One", // display name
"title_key": "ch1_title", // translated title
"path": "story", // story folder
"start": "start", // first scene id
"mode": "anthology", // episode or anthology
"unlock": { // when it becomes available
"always": true, // always unlocked
"flag": "ch1_done" // unlock flag
},
"clear_flag": "ch1_done" // flag set on finish
} ]
}
Keys
id stringtitle stringidtitle_key stringtitlepath stringstoryproject folderscript.txt / story.json.start stringstartstart; if that id is missing, the first moment in the story is used. Script sugar: :: start.mode enumanthologyepisode · anthology"episode" unlocks in order; "anthology" is free pick.unlock.always booltrue when unlock is omitted entirelytrue · falseunlock is omitted.unlock.flag stringflag nameclear_flag stringflag nameepisode mode).Learn more: stories
extensions
Advanced: your own code packs.
{
"extensions": [ { // advanced: your own code packs
"id": "parallax", // extension id
"entry": "extensions/parallax/main.gd", // main script path
"runtime_min": "0.1.0" // oldest player version
} ]
}
Keys
id stringlowercase_id[a-z0-9_]+). Required.entry pathproject pathruntime_min stringsemverLearn more: extensions
screens.json — file map
Optional defaults, then screen ids you choose — any names you need. title, pause, settings, and hud are common starters, not a closed list. Every screen uses the same shape (documented below under the example id title). Copy from examples/ and change labels first.
The whole file
Keys with { … } open below. Linked names jump to that section. Grey notes say what each key does. Under each section, Keys lists type, If omitted, allowed values, and a short note.
{
"defaults": { … }, // shared enter/exit fade for every screen that does not set its own
"title": { … }, // example id only — use any name (`pause`, `credits`, `gallery`, …); every screen uses this shape
"…": { … } // any other screen ids you invent — same shape
}
defaults
Shared enter/exit fade for every screen that does not set its own.
{
"defaults": { // shared enter/exit fade for every screen that does not set its own
"transition_in": "fade", // how screens appear
"transition_out": "fade", // how screens leave
"transition_duration": 0.3 // fade/slide length
}
}
Keys
transition_in enuminstantinstant · fade · slide_up · slide_down · slide_left · slide_righttransition_out enuminstantinstant · fade · slide_up · slide_down · slide_left · slide_righttransition_duration number00 = instant.Learn more: defaults
title
Example id only — use any name (pause, credits, gallery, …); every screen uses this shape.
{
"title": { // example id only — use any name (`pause`, `credits`, `gallery`, …); every screen uses this shape
"role": "modal", // how the screen sits in the world
"layers": [ { // ordered plates drawn bottom → top (required)
"type": "menu", // what this layer is
"order": 10, // draw order
"layout": {"host": "screen", "anchor_h": "center", "anchor_v": "center"}, // where the layer sits
"style": {"bg_color": "#101018"}, // look of this layer
"buttons": [ { // menu layer only — the button column
"label": "New Game", // caption
"label_key": "screen.title.new_game", // translated caption
"interaction": {"hooks": {"on_click": "new_game"}}, // what the button does
"show_if": "has_autosave", // when to show
"icon": "sprites/ico-pause.png" // optional glyph
} ],
"blocks": [ { // content layer only — rows inside the card
"type": "heading", // block kind
"bind": "bgm", // settings preference id
"label": "Music", // row caption
"text": "Audio", // copy for heading/text
"interaction": {"hooks": {"on_click": "close"}} // hooks for buttons/controls
} ],
"items": [ { // sprites layer only — UI pictures
"image": "ui/logo.png", // picture path
"interaction": {"hooks": {"on_click": "close"}} // click / hover hooks
} ],
"interaction": {"hooks": {"on_click": "close"}}, // layer hooks (background / chrome)
"modulate": "#ffffff", // layer tint
"scale": 1.0 // layer scale
} ],
"pauses_tree": false, // pause the game while open
"transition_in": "fade", // this screen's enter
"transition_out": "fade", // this screen's exit
"transition_duration": 0.3, // this screen's fade length
"interaction": {}, // screen-level hooks
"shader": {}, // screen shader
"action_context": "", // hook allowlist context
"modulate": "#ffffff", // whole-screen tint
"scale": 1.0, // whole-screen scale
"plane": "", // draw plane override
"persistent": false, // stay when others stack
"input_passthrough": false // clicks pass empty areas
}
}
Keys
role enummodalmodal · hud · scenemodal = full menu (default). hud = stays up during play. scene = under characters.layers[].type enumbackground · menu · content · sprites · chromebackground plate · menu button column · content card of blocks · sprites UI pictures · chrome extra controls (e.g. Close).layers[].order numberlayers[].layout objecthost: screen or panel (dock to dialogue box).layers[].style objectsource pack compose.layers[].buttons[].label stringlabel_key when translating.layers[].buttons[].label_key stringlabellayers[].buttons[].interaction objecthooks.on_click is a verb string (new_game, close, screen:pause), a command object, or an array of those.layers[].buttons[].show_if stringhas_autosave for Continue).layers[].buttons[].icon pathlayers[].blocks[].type enumbind is setheading · text · separator · link · spacer · image · button · button_row · slider · toggle · history · save_slots · scroll · tabletype when using preference bind instead. Common: heading, bind (as field), history, save_slots.layers[].blocks[].bind stringmaster · bgm · sfx · typing_enabled · typing_speed_scale · auto_advance_default · locale · …type when bind is set.layers[].blocks[].label stringbind, builds a settings row (label | control).layers[].blocks[].text stringtext_key or nonetext / heading body. text accepts BBCode.layers[].blocks[].interaction objectlayers[].items[].image pathkind: image sprites.layers[].items[].interaction objectlayers[].interaction objecton_click: close for click-outside. Chrome uses the same hook shape.layers[].modulate colorlayers[].scale number1[x,y]).pauses_tree boolfalsetrue · falsepause). HUD screens never pause.transition_in enumdefaultsinstant · fade · slide_up · slide_down · slide_left · slide_rightdefaults.transition_in for this screen only.transition_out enumdefaultsinstant · fade · slide_up · slide_down · slide_left · slide_rightdefaults.transition_out.transition_duration numberdefaultsdefaults.transition_duration.interaction objectshader objectaction_context stringmodulate colorscale number1plane stringrolerole.persistent boolrole (hud is persistent)input_passthrough boolrole (hud passes through)Learn more: title
themes/default.json — file map
Fonts, colours, dialogue-box presets, menus, and control chrome for the whole game. Most games start from an example themes/default.json and change colours and sizes. Choice and text-input packs are separate files under themes/ — see the Look pages. Extra root objects (or themes/<name>.json) are named button packs with the same keys as buttons.
The whole file
Keys with { … } open below. Linked names jump to that section. Grey notes say what each key does. Under each section, Keys lists type, If omitted, allowed values, and a short note.
{
"typography": { … }, // fonts and sizes
"colors": { … }, // fallback text and empty-stage colours
"panel": { … }, // named dialogue-box presets — any names you choose
"labelplate": { … }, // named name-plate styles
"buttons": { … }, // default menu / screen button chrome
"scrollbar": { … }, // menus and panel body scrollbars
"slider": { … }, // settings volume and similar rows
"toggle": { … }, // on/off settings controls
"card": { … }, // content cards on screens
"separator": { … }, // thin rules between blocks
"notification": { … }, // toast chrome (Saved, Loaded, …)
"settings_layout": { … }, // label / control columns on settings screens
"readonly_styles": { … }, // looks when a panel is readonly / not talking
"…": { … } // named button packs you add — same keys as buttons
}
typography
Fonts and sizes.
{
"typography": { // fonts and sizes
"font": "Jupiteroid", // base face under fonts/
"font_menu": null, // menus and settings
"font_dialogue": null, // dialogue and choices
"font_label": "Jupiteroid", // speaker name plate
"size_scale": 1.0, // size multiplier
"sizes": { // pixel sizes
"speaker": 45, // name plate
"dialogue": 30, // body text
"choice": 30, // choice captions
"button": 30, // menu buttons
"notification": 30 // toasts
}
}
}
Keys
font stringfonts/ (variants -Regular / -Bold when present).font_menu stringfontnull or omit → font.font_dialogue stringfontnull or omit → font.font_label stringfontsize_scale number1.0sizes.*. Player text-size builds on this.sizes.speaker numbersizes.dialogue numbersizes.choice numbersizes.button number20sizes.notification numberLearn more: typography
colors
Fallback text and empty-stage colours.
{
"colors": { // fallback text and empty-stage colours
"background_default": "#dfdcd7", // empty stage fill
"speaker_text": "#c4693a", // name plate text
"dialogue_text": "#e8e5e0", // body text
"choice_text": "#c5cad5", // choice captions
"notification_text": "#dfdcd7" // toast text
}
}
Keys
background_default color#1e2347speaker_text color#b3bfe6dialogue_text color#ffffffchoice_text color#c8d0ffnotification_text color#e6f0ffLearn more: colors
panel
Named dialogue-box presets — any names you choose.
{
"panel": { // named dialogue-box presets — any names you choose
"default": { // any name you choose
"extends": "default", // inherit another preset
"width": "80%", // panel width
"anchor_h": "center", // horizontal place
"anchor_v": "bottom", // vertical place
"offset_x": 0, // nudge X
"offset_y": -32, // nudge Y
"min_height": 160, // height floor
"max_height": "50%", // height ceiling
"padding": {"top": 15, "left": 20, "right": 20, "bottom": 20}, // inner margins
"speaker_gap": 6, // name to text gap
"choices_gap": 2, // text to choices gap
"choices_separation": 4, // between choices
"bg": "#343a52e0", // flat plate colour
"bg_texture": "sprites/panel-bg.png", // plate image
"bg_9slice": [15, 15, 15, 15], // nine-slice margins
"corner_radius": 0, // flat corner radius
"resize_duration": 0, // height ease
"scrollbar_layout": "default", // body scrollbar column
"typography": {"font": "JetBrainsMono", "sizes": {"dialogue": 18, "choice": 18}}, // panel font / sizes overlay
"colors": {"dialogue_text": "#c8ffd0", "speaker_text": "#7CFF8A"}, // panel body / speaker colours
"labelplate_style_key": "default", // name plate style
"labelplate_layout": {"anchor_h": "left", "anchor_v": "top", "offset_y": -47}, // docked name plate
"continue_button": {"visible": true}, // next / continue control
"back_button": {"visible": false}, // back control
"slots": [], // avatar / art slots
"default_plate": "them", // fallback plate name
"plates": { // named per-beat plates for text_mode plates
"them": { // any name you choose
"extends": "them", // inherit another plate
"anchor_h": "left", // side of text column
"width": "88%", // plate width
"speaker": "on_change", // in-plate speaker header
"speaker_gap": 4, // header to body gap
"bg": "#2b3552", // flat plate colour
"corner_radius": 14, // flat corner radius
"bg_texture": "sprites/plate-bg.png", // plate image
"bg_9slice": [12, 12, 12, 12], // nine-slice margins
"shader": {"name": "drop_shadow", "target": "bg"}, // background shader
"padding": {"top": 8, "left": 12, "right": 12, "bottom": 8}, // inner margins
"margin": {"top": 3, "bottom": 3, "left": 0, "right": 0}, // outer spacing
"group_gap": 6, // extra gap on speaker change
"typography": {"sizes": {"speaker": 13}}, // plate font / sizes overlay
"colors": {"dialogue_text": "#e8ecff", "speaker_text": "#8aa0c8"}, // plate body / speaker colours
"transition_in": "slide_right", // live plate enter
"transition_out": "fade", // validated; unused on fold
"transition_duration": 0.22, // enter duration
"transition": "fade", // enter fallback
"duration": 0.22 // duration alias
}
}
}
}
}
Keys
extends stringwidth string100%"NN%", "100%", "stretch", or a pixel number.anchor_h enumstretchleft · center · right · stretchanchor_v enumbottomtop · center · bottom · stretchoffset_x number0offset_y number0min_height number160"NN%", or "full".max_height number"NN%", or "full".padding objecttop / left / right / bottom pixels.speaker_gap number6choices_gap number2choices_separation number4bg color#343a52e0bg_texture.bg_texture pathbg onlybg_9slice array[left, top, right, bottom] for bg_texture.corner_radius number0resize_duration number00 snaps.scrollbar_layout enumdefaultdefault · reserve · overlayreserve keeps wrap width stable on scrolling plates.typography objectcolors objectlabelplate_style_key stringlabelplate.<name> styles the speaker.labelplate_layout objectcontinue_button objectstyle.back_button objectcontinue_button.slots arraydefault_plate stringplates.<name> to use when the beat does not pick one. Must name a key in plates.plates.*.extends stringplates.*.anchor_h enumleftleft · center · right · stretchplates.*.width string80%"NN%" of the text column, a pixel number, or "stretch".plates.*.speaker enumfalsefalse · true · on_changeon_change shows the header when the speaker changes.plates.*.speaker_gap numberplates.*.bg colorbg_texture.plates.*.corner_radius number0plates.*.bg_texture pathbg onlyplates.*.bg_9slice array[left, top, right, bottom] for bg_texture.plates.*.shader objecttarget: "bg" only on plates.plates.*.padding objecttop / left / right / bottom pixels.plates.*.margin objectplates.*.group_gap number0plates.*.typography objectplates.*.colors objectplates.*.transition_in enuminstantinstant · fade · slide_up · slide_down · slide_left · slide_rightslide_right enters from the left; slide_left from the right.plates.*.transition_out enuminstant · fade · slide_up · slide_down · slide_left · slide_rightplates.*.transition_duration number0transition_in. 0 or instant = no motion.plates.*.transition enuminstant · fade · slide_up · slide_down · slide_left · slide_righttransition_in is omitted.plates.*.duration numbertransition_duration when that key is absent.Learn more: panel
labelplate
Named name-plate styles.
{
"labelplate": { // named name-plate styles
"default": { // any name you choose
"bg_texture": "sprites/label-bg.png", // plate image
"bg_9slice": [7, 7, 7, 17], // nine-slice margins
"padding_left": 12, // inner left
"padding_right": 12, // inner right
"padding_top": 2, // inner top
"padding_bottom": 12, // inner bottom
"font_color": "#3a2a1a" // name colour
}
}
}
Keys
bg_texture pathbg_9slice arraybg_texture.padding_left numberpadding_right numberpadding_top numberpadding_bottom numberfont_color colorcolors.speaker_text for this style.Learn more: labelplate
buttons
Default menu / screen button chrome.
{
"buttons": { // default menu / screen button chrome
"spacing": 12, // menu separation
"min_width": 220, // minimum width
"corner_radius": 0, // corner radius
"border_width": 0, // border width
"padding_left": 18, // padding left
"padding_right": 18, // padding right
"padding_top": 4, // padding top
"padding_bottom": 6, // padding bottom
"normal_bg": "#d8c9a8", // normal fill
"hover_bg": "#e4d6b4", // hover fill
"pressed_bg": "#c4b48e", // pressed fill
"font_color": "#3a2a1a", // label colour
"font_hover_color": "#6b4420", // hover label
"font_pressed_color": "#8a5a28", // pressed label
"normal_texture": "sprites/button-normal.png", // idle image
"hover_texture": "sprites/button-hover.png", // hover image
"pressed_texture": "sprites/button-down.png" // pressed image
}
}
Keys
spacing numbermin_width number0corner_radius numberborder_width numberpadding_left numberpadding_right numberpadding_top numberpadding_bottom numbernormal_bg colorhover_bg colorpressed_bg colorfont_color colorfont_hover_color colorfont_pressed_color colornormal_texture pathhover_texture pathpressed_texture pathLearn more: buttons
scrollbar
Menus and panel body scrollbars.
{
"scrollbar": { // menus and panel body scrollbars
"width": 10, // bar width
"corner_radius": 4, // corner radius
"content_separation": 18, // gap from content
"track_color": "#8a6a4a40", // track colour
"grabber_color": "#6b4420", // grabber
"grabber_hover_color": "#8a5a28", // grabber hover
"grabber_pressed_color": "#3a2a1a", // grabber pressed
"track_9slice": [0, 0, 0, 0], // track nine-slice
"grabber_9slice": [0, 0, 0, 0] // grabber nine-slice
}
}
Keys
width number10corner_radius number4content_separation numbertrack_color color#4a559060grabber_color color#5b6a82grabber_hover_color color#6a75b0grabber_pressed_color color#a8b8eftrack_9slice arraygrabber_9slice arrayLearn more: scrollbar
slider
Settings volume and similar rows.
{
"slider": { // settings volume and similar rows
"track_height": 9, // track height
"corner_radius": 4, // corner radius
"grabber_size": 19, // knob size
"track_color": "#4a559060", // track colour
"fill_color": "#c4693a90", // filled track
"fill_hover_color": "#d4794ab0", // fill hover
"grabber_color": "#c5cad5", // knob colour
"grabber_hover_color": "#dfdcd7", // knob hover
"grabber": "sprites/slider-picker.png", // knob image
"grabber_hover": "sprites/slider-picker.png", // knob hover image
"track_texture": "sprites/slider-bg.png", // track image
"fill_texture": "", // fill image
"fill_hover_texture": "", // fill hover image
"track_9slice": [4, 6, 4, 6], // track nine-slice
"fill_9slice": [4, 6, 4, 6] // fill nine-slice
}
}
Keys
track_height number8corner_radius number4grabber_size number20track_color color#4a559060fill_color color#c4693a90fill_hover_color colorgrabber_color color#c5cad5grabber_hover_color color#dfdcd7grabber pathgrabber_hover pathtrack_texture pathfill_texture pathfill_hover_texture pathtrack_9slice arraytrack_texture.fill_9slice arrayfill_texture.Learn more: slider
toggle
On/off settings controls.
{
"toggle": { // on/off settings controls
"checked": "sprites/checked.png", // on image
"unchecked": "sprites/unchecked.png", // off image
"checked_hovered": "sprites/checked.png", // on hover image
"unchecked_hovered": "sprites/unchecked.png", // off hover image
"icon_size": 0, // icon size
"track_width": 48, // switch width
"track_height": 26, // switch height
"knob_size": 20, // knob size
"corner_radius": 13, // track radius
"track_on_color": "#c4693a90", // on track
"track_off_color": "#4a559060", // off track
"knob_color": "#dfdcd7", // switch knob
"knob_hover_color": "#dfdcd7", // knob hover
"track_on_hover_color": "#d4794acc", // on track hover
"track_off_hover_color": "#6a75a8a0" // off track hover
}
}
Keys
checked pathunchecked pathchecked_hovered pathunchecked_hovered pathicon_size number00 uses the texture size.track_width number48track_height number26knob_size number20corner_radius number13track_on_color color#c4693a90track_off_color color#4a559060knob_color color#dfdcd7knob_hover_color colortrack_on_hover_color colortrack_off_hover_color colorLearn more: toggle
card
Content cards on screens.
{
"card": { // content cards on screens
"max_width": 520, // card width cap
"padding_left": 36, // padding left
"padding_right": 36, // padding right
"padding_top": 28, // padding top
"padding_bottom": 28, // padding bottom
"spacing": 14, // inner gap
"bg": "#dfdcd7e0", // card fill
"corner_radius": 0, // corner radius
"bg_texture": "sprites/screen-bg.png", // card image
"bg_9slice": [7, 7, 7, 7] // nine-slice margins
}
}
Keys
max_width number500padding_left number40padding_right number40padding_top number30padding_bottom number30spacing number20bg color#1e234790corner_radius number12bg_texture pathbg_9slice arraybg_texture.Learn more: card
separator
Thin rules between blocks.
{
"separator": { // thin rules between blocks
"height": 2, // rule thickness
"color": "#8a6a4a60" // rule colour
}
}
Keys
height number2color color#ffffff60Learn more: separator
notification
Toast chrome (Saved, Loaded, …).
{
"notification": { // toast chrome (Saved, Loaded, …)
"bg": "#1e2347cc", // toast fill
"bg_texture": "", // toast image
"bg_9slice": [0, 0, 0, 0], // nine-slice margins
"corner_radius": 10, // corner radius
"padding_left": 24, // padding left
"padding_right": 24, // padding right
"padding_top": 10, // padding top
"padding_bottom": 12, // padding bottom
"min_width": 0, // minimum width
"offset_y": 0, // vertical nudge
"fade_in": 0.15, // fade in
"fade_out": 0.2 // fade out
}
}
Keys
bg color#1e2347ccbg_texture pathbg_9slice arraybg_texture.corner_radius number10padding_left number24padding_right number24padding_top number10padding_bottom number12min_width numberoffset_y numberfade_in numberfade_out numberLearn more: notification
settings_layout
Label / control columns on settings screens.
{
"settings_layout": { // label / control columns on settings screens
"title_font_size": 28, // section heading size
"label_min_width": 260, // label column
"slider_min_width": 200, // slider column
"value_min_width": 60, // value readout
"item_spacing": 16 // row gap
}
}
Keys
title_font_size number28label_min_width number180slider_min_width number200value_min_width number60item_spacing number16Learn more: settings_layout
readonly_styles
Looks when a panel is readonly / not talking.
{
"readonly_styles": { // looks when a panel is readonly / not talking
"default": { // any name you choose
"text_color": "#aaaaaa", // dimmed body
"speaker_color": "#888888", // dimmed name
"bg_opacity": 0.7, // plate alpha
"font_scale": 1.0 // size scale
}
}
}
Keys
text_color color#ffffffspeaker_color color#b3bfe6bg_opacity number1.0font_scale number1.0Learn more: readonly_styles
story/story.json — file map
A JSON array of moments. With script.txt, each entry patches the matching script scene by id (direction only — dialogue usually stays in the script). Without a script, the array is the whole story. You rarely type every field by hand; start from The same beat as data.
The whole file
The file is a JSON array. Each { … } is one moment. Linked entries jump to that section. Under each section, Keys lists type, If omitted, allowed values, and a short note.
[
{ … }, // one moment — any id; with a script, match :: id
{ … } // more moments — same shape
]
moment
One moment — any id; with a script, match :: id.
{
"id": "park", // moment id
"talking": "hana", // who speaks
"talking_anchor": "hana", // initial focus
"label": "Hana", // name-plate text
"text": "Watch the meter.", // dialogue body
"text_ref": "line.park.1", // strings catalog key
"text_from": "earlier_node", // copy text from node
"set": {"met_hana": true}, // flags on enter
"commands": [{"type": "bg", "value": "street"}], // enter commands
"flow_commands": [[{"type": "sfx", "value": "click"}]], // mid-line batches
"exit_commands": [{"type": "char", "name": "hana", "show": false}], // leave commands
"exit_delay": 0.3, // pause after exit
"next": "after", // linear target
"next_if": [{"expr": "score > 5", "then": "good"}], // conditional next
"auto": 2.0, // auto-advance
"choices": { // choice menu — full tables in Story detail
"items": [ { // the options (required)
"text": "Stay", // caption
"text_key": "choice.stay", // translated caption
"target": "stay", // where it goes
"set": {"chose_stay": true}, // flags on pick
"commands": [{"type": "sfx", "value": "click"}], // commands on pick
"id": "stay", // option id
"rules": {"show_if": "has_key"}, // show / disable
"style": {}, // this option's look
"layout": {"x": 100, "y": 80}, // free placement
"source": "choice_plain", // pack stem
"interaction": {}, // hotkey / hooks
"transition": {}, // option motion
"shader": {}, // option shader
"audio": {}, // option sounds
"state_visual": {}, // state look
"debug": {} // author debug
} ],
"source": "choice_yes_no", // menu pack
"style": "choice_button", // button pack
"item": {}, // shared item defaults
"shuffle": false, // reshuffle options
"show_numbers": true, // number prefix
"container": {}, // menu plate / layout
"timer": {"seconds": 30, "on_end": "explode"} // choice deadline
},
"input": { // text field wait
"flag": "player_name", // where to store
"prompt": "Your name?", // prompt copy
"default": "", // ghost + empty confirm
"placeholder": "Type a name…", // hint only
"prefill": "Hero", // editable starter
"prefill_from_flag": true, // seed from flag
"max_length": 24, // character cap
"pattern": "^[A-Za-z ]+$", // validation
"style": "name_entry", // chrome pack
"source": "name_entry", // pack stem
"container": {}, // field host
"field": {}, // field chrome
"confirm": {} // confirm button
},
"text_metas": {"help": "url:https://example.com"}, // body link actions
"panel": "default", // panel preset
"panel_id": "main", // which panel
"text_mode": "replace", // body write mode
"append_direction": "down", // append / plates direction
"append_gap_lines": 0, // append line gap
"append_gap_px": 0, // append pixel gap
"plate": "them", // plate name this moment
"hide_panel": false, // hide dialogue box
"hide_continue": false, // hide continue
"hide_back": false, // hide back
"driver_typing": true, // mouth / beep drive
"driver_typing_targets": ["hana"], // who gets drive
"panel_shader": {}, // panel shader
"panel_presence_mode": "talk_only", // when panel shows
"panel_non_talking_mode": "hide", // when not talking
"panel_readonly_style_key": "default", // readonly look
"labelplate": {"visible": true} // name plate override
}
Keys
id string_N from file order:: park in the script. Required for direction patches.talking stringtalking_anchor stringlabel stringtext string{@…} tags, and {flag} inserts.text_ref stringtexttext_from stringset object"= expr".commands arrayflow_commands array{@cN} markers in text (1-based). Command shapes — [Commands](../commands/index.md).exit_commands arrayexit_delay number0exit_commands before the next moment.next stringnull / END / NEXT / START.next_if arraynext{ expr, then }; else next.auto number[auto N]).choices.items[].text stringchoices.items[].text_key stringchoices.items[].target stringNEXT / END / START.choices.items[].set objectchoices.items[].commands arraychoices.items[].id stringchoices.items[].rules objectshow_if / disable_if expressions.choices.items[].style objectchoices.items[].layout objectchoices.items[].source stringthemes/.choices.items[].interaction objectchoices.items[].transition objectchoices.items[].shader objectchoices.items[].audio objectchoices.items[].state_visual objectchoices.items[].debug objectchoices.source stringthemes/ for the whole menu.choices.style stringthemes/<name>.json).choices.item objectchoices.shuffle boolfalsechoices.show_numbers booltrueN. .choices.container objectchoices.timer objectinput.flag stringinput.prompt stringinput.default stringinput.placeholder stringinput.prefill stringinput.prefill_from_flag boolfalseinput.max_length numberinput.pattern stringinput.style stringthemes/ for the field look.input.source stringinput.container objectinput.field objectinput.confirm objecttext_metas object[url=key] markers to a url string or jump/set_flag object.panel stringpanel_id stringtext_mode enumreplacereplace · append · platesappend_direction enumdowndown · upappend_gap_lines number0plates).append_gap_px number0plate stringplates.<name> to use for this moment when text_mode is plates. Prefer character ui.default_plate for chat sides.hide_panel boolfalsehide_continue boolfalsehide_back boolfalsedriver_typing booltruedriver_typing_targets arraypanel_shader objectpanel_presence_mode enumtalk_only · visible_with_char · always_visiblepanel_non_talking_mode enumhide · readonly_last_textpanel_readonly_style_key stringreadonly_styles.<name> to use.labelplate objectlabelplate command.Learn more: moment
characters/{id}/{pack}.json — file map
One art pack for a character. Folder characters/<id>/; pack file e.g. default.json. Expression names are free. Reserved filenames (not packs): sprites.json, objects.json, metadata.json, profiles.json.
The whole file
Keys with { … } open below. Linked names jump to that section. Grey notes say what each key does. Under each section, Keys lists type, If omitted, allowed values, and a short note.
{
"display_name": "Emily", // speaker label
"display_name_key": "char.emily.name", // translated label
"ui": { … }, // panel and draw defaults for this pack
"drive": {}, // mouth / focus rules
"composition_transitions": {}, // expression swap fades
"default": [ { … } ], // example expression — layers bottom → top
"…": { … } // more expression ids — layer arrays or inherit objects
}
Root keys
Top-level keys that are not objects.
display_name_key stringcomposition_transitions objectLearn more: display_name · drive · composition_transitions
ui
Panel and draw defaults for this pack.
{
"ui": { // panel and draw defaults for this pack
"default_panel_id": "main", // panel instance
"default_panel_preset": "default", // panel preset
"default_plate": "them", // plates mode side
"default_labelplate_style_key": "default", // name plate style
"draw_layer": "behind_panel", // draw plane
"panel_slots": {} // slot art map
}
}
Keys
default_panel_id stringdefault_panel_preset stringpanel.<name> preset.default_plate stringplates.<name> this character prefers under text_mode plates.default_labelplate_style_key stringlabelplate.<name> style.draw_layer enumbehind_panel · above_panelpanel_slots objectLearn more: ui
default
Example expression — layers bottom → top.
{
"default": [ { // example expression — layers bottom → top
"path": "base.png", // layer image
"z": 0, // draw order
"offset": [0, 0], // pixel nudge
"frames": [], // flipbook frames
"fps": 12 // flipbook rate
} ]
}
Keys
path pathz numberoffset array[x, y] offset.frames arraypath.fps numberLearn more: default
cursors.json — file map
Soft pointer profiles. Pick a default id, then define each look under profiles. Locale overlay: cursors_<locale>.json.
The whole file
Keys with { … } open below. Linked names jump to that section. Grey notes say what each key does. Under each section, Keys lists type, If omitted, allowed values, and a short note.
{
"default": "default", // active profile id
"profiles": { … } // named pointer looks — any ids you choose
}
Root keys
Top-level keys that are not objects.
Learn more: default
profiles
Named pointer looks — any ids you choose.
{
"profiles": { // named pointer looks — any ids you choose
"default": { // any name you choose
"kind": "image", // what draws the pointer
"image": "sprites/pointer.png", // pointer picture
"object": "custom_cursor", // object id
"hotspot": [2, 2], // click point
"scale": 1.0, // size
"modulate": "#ffffff", // tint
"opacity": 1.0, // alpha
"shader": {} // pointer shader
}
}
}
Keys
kind enumimage · objimage = PNG path · obj = object definition id.image pathobject stringsprites/<id>.obj.json.hotspot array[x, y] hotspot in the image.scale number1.0modulate coloropacity number1.0shader objectLearn more: profiles
keybindings.json — file map
Action → list of keys. You only override the actions you care about; each listed action fully replaces the built-in keys for that name. Locale overlay: keybindings_<locale>.json.
The whole file
Keys with { … } open below. Linked names jump to that section. Grey notes say what each key does. Under each section, Keys lists type, If omitted, allowed values, and a short note.
{
"dialogue_advance": ["space", "enter", "mouse_left"], // advance / confirm
"dialogue_back": ["backspace"], // rollback
"pause": ["escape"], // pause screen
"save": ["s"], // quick save
"load": ["l"], // quick load
"quit": ["q"], // quit
"choice_1": ["1"], // choice 1
"choice_2": ["2"], // choice 2
"choice_3": ["3"], // choice 3
"choice_4": ["4"], // choice 4
"choice_5": ["5"], // choice 5
"choice_prev": ["up"], // previous choice
"choice_next": ["down"], // next choice
"choice_cycle": ["space"], // cycle choices
"choice_confirm": ["enter", "kp_enter"], // confirm choice
"debug_toggle": ["f12"], // debug overlay
"debug_author_refresh": ["f5"], // reload project
"debug_ui_refresh": ["f6"], // reload look
"debug_switch_project": ["f2"], // switch project
"…": { … } // other actions you map the same way
}
Root keys
Top-level keys that are not objects.
dialogue_advance arraydialogue_back arraypause arraysave arrayload arrayquit arraychoice_1 arraychoice_2 arraychoice_3 arraychoice_4 arraychoice_5 arraychoice_prev arraychoice_next arraychoice_cycle arraychoice_confirm arraydebug_toggle arraydebug_author_refresh arraydebug_ui_refresh arraydebug_switch_project arrayLearn more: dialogue_advance
sprites/{id}.obj.json — file map
Reusable object definitions. One file per id under sprites/. Instances use the obj command. See Objects.
The whole file
Keys with { … } open below. Linked names jump to that section. Grey notes say what each key does. Under each section, Keys lists type, If omitted, allowed values, and a short note.
{
"id": "lamp_widget", // definition id
"layer": "overlay", // default draw plane
"transition_in": "fade", // default show
"transition_out": "fade", // default hide
"transition_duration": 0.3, // default fade length
"modulate": "#ffffff", // root tint
"scale": 1.0, // root scale
"rotation_degrees": 0, // root rotation
"parts": [ { … } ] // pieces of the object (required)
}
Root keys
Top-level keys that are not objects.
layer enumbg_front · fg · overlaytransition_in enuminstant · fade · slide_up · slide_down · slide_left · slide_righttransition_out enuminstant · fade · slide_up · slide_down · slide_left · slide_righttransition_duration numbermodulate colorscale number[sx, sy].rotation_degrees numberLearn more: id
parts
Pieces of the object (required).
{
"parts": [ { // pieces of the object (required)
"id": "ptr", // part id
"kind": "polygon", // shape
"x": 0, // local x
"y": 0, // local y
"width": 40, // width
"height": 20, // height
"order": 0, // draw order
"points": [[0, 0], [10, 0], [5, 8]], // polygon points
"color": "#343a52e0", // fill colour
"opacity": 1.0, // alpha
"visible": true, // shown
"path": "sprites/face.png", // image path
"text": "OK", // caption
"bind": {"y": {"flag": "score", "map": {"5": 23}}}, // live flag links
"bbcode_enabled": false, // rich text
"font_size": 16, // text size
"align_h": "left", // text align
"autowrap": false // wrap text
} ]
}
Keys
id stringobj update patches.kind enumimage · rect · polygon · textx numbery numberwidth numberheight numberorder numberpoints array[[x,y], …] for kind: polygon.color coloropacity numbervisible booltruepath pathkind: image.text stringkind: text.bind objectbbcode_enabled boolfalsekind: text.font_size numberkind: text.align_h enumleft · center · rightkind: text.autowrap boolfalsekind: text.Learn more: parts
animations/presets.json — file map
Named animation presets. Reference with "preset": "nudge" on an animate command. Built-ins (shake, pulse, …) work without this file. Locale overlay: presets_<locale>.json.
The whole file
Keys with { … } open below. Linked names jump to that section. Grey notes say what each key does. Under each section, Keys lists type, If omitted, allowed values, and a short note.
{
"nudge": { … }, // example preset id — any name you choose
"…": { … } // more preset ids — same fields as animate
}
nudge
Example preset id — any name you choose.
{
"nudge": { // example preset id — any name you choose
"property": "position_y", // what to tween
"from": 1.0, // start
"to": -8, // end
"duration": 0.12, // length
"ease": "in_out", // ease
"trans": "sine", // transition curve
"loop": "ping_pong", // repeat
"count": 2, // repeat count
"relative": true, // relative to
"keyframes": [], // keyframe list
"frames": [], // frame list
"fps": 12, // frame rate
"intensity": 1.0, // shake strength
"distance": 8, // travel distance
"start_frame": 0, // first frame
"color": "#ffffff", // tint end
"opacity": 1.0, // alpha end
"scale_from": 1.0, // scale start
"scale_to": 1.05, // scale end
"decay": true // shake decay
}
}
Keys
property stringposition_x, position_y, scale, opacity, modulate.from numberto numberduration numberease enumin · out · in_out · out_intrans enumlinear · sine · cubic · quad · quint · expo · elastic · back · bounceloop enumtrue · false · ping_pongtrue, false, or ping_pong.count number0 = forever when looping).relative boolfalseto / from as deltas.keyframes arrayframes arrayfps numberintensity numberdistance numberstart_frame numbercolor coloropacity numberscale_from numberscale_to numberdecay boolLearn more: nudge
Everything A–Z
One flat list of common names: commands, script marks, important keys, and actions. One line each — use your browser search (Ctrl+F / ⌘F).
This list gathers the words used across the handbook. Full command tables: Commands. Words these docs use: Glossary.
Script marks
| Name | What it does | Where |
|---|---|---|
NAME: | Speaker line | Lines and speakers |
NAME [expr]: | Speak + stage expression | Lines and speakers |
(silent) | No typing mouth / beep for that speaker | Lines and speakers |
Label|… | Custom name-plate text for the line | Lines and speakers |
[…] | Stage direction / keyword command | Stage directions |
:: id | Scene / moment id | Scenes and jumps |
-> id | Jump | Scenes and jumps |
> / choice lines | Choice menu | Choices and memory |
{@w} / {@cps} / {@p} | Mid-line pacing marks | Pacing |
{@cN} | Mid-line command cue (flow_commands) | Pacing · Command flow |
[if …] / [flag = …] | Branch / set memory | Choices and memory |
Commands
| Name | What it does | Where |
|---|---|---|
animate / stop_animate | Tween or stop motion on stage / UI | animate · stop_animate |
bg | Background picture / clear | bg |
bgm / sfx / typing_sfx | Music and sounds | bgm · sfx · typing_sfx |
call / return | Call a scene as a subroutine | call · return |
camera | Pan / zoom (in-dev stage camera) | camera |
char | Show / move / hide / look a character | char |
char_layout | Switch character place profile | char_layout |
char_pack | Switch active art pack | char_pack |
choice_style | Sticky choice look pack | choice_style |
cursor | Soft pointer profile | cursor |
input / input_style | Ask for typed text | input · input_style |
jump / jump_if | Go to a moment | jump · jump_if |
labelplate | Override speaker name plate | labelplate |
obj / sprite | Stage props | obj · sprite |
panel | Dialogue box look / append / policy | panel |
screen | Open / close a menu or HUD | screen |
screen_shader | Full-screen effect | screen_shader |
set_flag | Set memory | set_flag |
stage | Location beat (in-dev) | stage |
theme_style | Mood theme overlay | theme_style |
timer | Timed wait / choice timer | timer |
| All commands | Index | Commands |
Moment / direction fields (common)
| Name | What it does | Where |
|---|---|---|
text / talking | What is said / who | The same beat as data |
next | Where to go after | Scenes and jumps |
choices / input | Branch or type | Choices · input |
commands | Enter directions | Command flow · Commands |
flow_commands | Mid-line batches fired by {@cN} | Command flow · Flow tags |
exit_commands / exit_delay | Leave directions + wait | Command flow · Pacing |
panel / panel_id / text_mode | Which box / append | Node fields · How text finds a panel |
when / apply | Skip or sticky | Shared shapes |
bind | Live flag → property | Reacting to flags live |
game.json (common)
| Name | What it does | Where |
|---|---|---|
title / version / authors | Identity | Name, window, language · Ship identity |
window | Base resolution | Name, window, language |
locale / i18n | Language | Translations |
icon / splash / android | Shell brand / Android id | Title, icon, splash · Platforms |
typing / sounds | Text speed / default SFX | Text speed · Default sounds |
session | Save slots, HUD id, ending, Back | Save, Back and story end |
locale / i18n | Base language + switcher | Translations |
layout | Choices / character places | Choice and character layout |
transitions / notifications / cursor | Veil / toasts / pointer | Transitions · Notifications · Cursor |
extensions | Native extension packs (advanced) | Native extensions · extensions API |
stories | Several story packs in one project (early) | Several stories |
| Full map | Every documented key | game.json map |
Screens and actions
| Name | What it does | Where |
|---|---|---|
role: modal / hud / scene | Screen kind | Screens |
Layers background / menu / content / sprites / chrome | Screen structure | Screens detail |
Content blocks / bind | Settings rows and cards | Content blocks · Widget bindings |
new_game / continue / quit | Title actions | Actions and hooks · Buttons and actions |
screen:<id> / close | Open / pop | Actions and hooks |
show_if | Conditional button | Screens |
has_autosave | Common continue gate | Save, Back and story end |
Theme blocks (common)
| Name | What it does | Where |
|---|---|---|
typography / colors | Fonts and fallback colours | themes map |
panel.<preset> | Dialogue box look | theme panel |
labelplate.<style> | Name-plate look | theme labelplate |
buttons / choice packs | Menu vs story choice looks | buttons · Choice packs |
scrollbar / notification | Bars and toasts | How a panel looks · Notifications |
Character pack (common)
| Name | What it does | Where |
|---|---|---|
display_name / ui | Label and preferred panel | Pack root · characters map |
Expression keys / extends | Layered looks | Expressions and layers |
drive | Talking / focus reactions | Drive rules |
composition_transitions | Expression fade policy | Composition transitions |
Layer frames / path | Still or flipbook | Expressions and layers |
Other project files
| Name | What it does | Where |
|---|---|---|
screens.json | Title, pause, HUD, … | screens map · Screens detail |
keybindings.json | Remap actions | keybindings map |
cursors.json | Soft cursor profiles | cursors map |
animations/presets.json | Author animate presets | animations map |
sprites/<id>.obj.json | Object definitions | objects map |
story/strings.json | Translation catalog | Strings · Translations |
Next
Detail pages
One page per command or settings group, with every option: type, default, what it does, and the rules that decide between them.
Commands
→ Commands — every stage direction (bg, char, panel, jump, …)
Story
→ Story detail — node fields, choices, script sugar, flow tags, Mode B
Overview: story/story.json map
Screens
→ Screens detail — layers, content blocks, binds, actions
Overview: screens.json map
Theme
→ Theme detail — typography, colours, panel, widgets, choice & input packs
Overview: themes/default.json map
Characters
→ Character pack detail — pack root, expressions, drive, composition fades
Overview: characters pack map
Other files
→ Other files — cursors, keys, objects, animation presets, strings, extensions
Maps: cursors · keybindings · objects · animations · game.json → extensions
Guide: Native extensions
File maps
| File | Map |
|---|---|
game.json | game.json |
screens.json | screens.json |
themes/default.json | themes/default.json |
story/story.json | story/story.json |
characters/<id>/<pack>.json | characters |
cursors.json | cursors.json |
keybindings.json | keybindings.json |
sprites/<id>.obj.json | objects |
animations/presets.json | animations |
Shared contracts
→ Shared shapes — transitions, texture specs, binds, hooks, state_visual, source, shaders
Use Everything A–Z to jump by name.
Next
Commands · Story · Screens · Theme · Characters · Other · File maps
Commands
Every stage direction and data command — full fields, types, and defaults. Guide pages teach when to use each; these pages list every key.
Commands live in:
- Script brackets:
[bg street fade 0.5] - Moment fields:
commands,flow_commands,exit_commands— story/story.json map - Some also as sticky / node fields (
input,labelplate, …)
Stage
| Command | Job |
|---|---|
bg | Background picture / clear |
stage | Location beat (in development) |
camera | Pan / zoom (in development) |
char | Show / move / hide / expression |
char_pack | Switch art pack |
char_layout | Switch place profile |
sprite | Layered stage picture |
obj | Reusable object instance |
Dialogue box and name
| Command | Job |
|---|---|
panel | Dialogue box look / append / policy |
labelplate | Speaker name plate |
Sound and motion
| Command | Job |
|---|---|
bgm | Music |
sfx | One-shot sound |
typing_sfx | Typing beep override |
animate | Tween / keyframes / frames |
stop_animate | Stop a running animation |
Flow and memory
| Command | Job |
|---|---|
jump | Go to a moment |
jump_if | Branch on expression |
call / return | Subroutine |
set_flag | Set memory |
timer | Timed wait / fire |
Menus, look, and chrome
| Command | Job |
|---|---|
screen | Open / close a screen |
screen_shader | Full-screen shader |
choice_style | Sticky choice pack |
input | Ask for typed text |
input_style | Sticky text-input pack |
theme_style | Mood theme overlay |
cursor | Soft pointer profile |
Shared fields that appear on many commands (when, transitions, texture specs, binds): Shared shapes.
Detail pages · Everything A–Z · Adding direction
bg
Shows or clears the stage background image.
{ "type": "bg", "value": "street", "transition": "fade", "duration": 0.5 }
[bg street fade 0.5]
[bg clear]
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
value | string or null | — | Image name under backgrounds/; null clears |
transition | transition kind | fade if omitted | Enter / leave |
duration | number ≥ 0 | 0.5 if omitted with default fade | Seconds |
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
value | string or null | — | Image / clear |
transition / transition_in / transition_out | kind | omit → fade | Kinds: instant, fade, slide_* |
transition_duration / duration | number ≥ 0 | omit → 0.5 | Seconds |
opacity | number 0–1 | 1 when omitted on a show | Plate alpha baseline (binds compose on top) |
modulate | color string | white when omitted on a show | Tint baseline |
visible | bool | true when omitted on a show | Show/hide baseline |
shader | string or object or null | absent = keep; null = clear | Stem under shaders/, or an object with name / params / target / pass / margin — Effects |
shader_params | object | — | Uniforms when setting shader |
bind | object | — | image, opacity, modulate, visible, plus shader:… |
interaction | object | — | hooks.on_show / on_hide / on_change |
when | expression | — | Skip when falsy |
Bind notes
bind.imageuses the same stems asvalue(files underbackgrounds/). Swaps are instant — use an explicitbgcommand when you want a fade.- Bind look keys compose with the command baselines above (
opacity/modulate/visible). - While a model background or an active background pack is showing,
image/opacity/modulate/visiblebinds are ignored (shader:…still applies when a shader is set).
Clearing the image also clears shader and bind. Guide: Backgrounds, Transitions, Binds, Shaders.
stage
Stage locations (in development).
This command exists in the runtime schema but is not ready for shipping stories yet.
Related
camera
Camera (in development).
This command exists in the runtime schema but is not ready for shipping stories yet.
Related
char
Shows, changes, moves, or hides a character.
{ "type": "char", "name": "hana", "expression": "happy", "position": "left", "transition": "fade", "duration": 0.4 }
In script.txt:
[hana happy left fade 0.4]
[hana] # default expression, current or auto-assigned slot
[hana hide]
Hana [happy]: Good morning. # implicit char command before the line
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
name | string | — | Character folder under characters/. Empty or missing hides whoever is in the resolved slot |
expression | string | "default" | Expression key in the pack. Unknown keys fall back to default |
position | string | see slot resolution | Slot alias (left, center, right, …) |
show | bool | true | false hides the character in the resolved slot |
transition | transition kind | "instant" | Enter and leave |
duration | number ≥ 0 | 0 | Seconds when transition_duration is absent |
That is enough for most stories. Everything below is the full surface.
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
name | string | — | Character folder under characters/. An empty or missing name hides whoever is in the resolved slot |
expression | string | "default" | Expression key in the pack. Unknown keys fall back to default |
pack | string | active pack | Pack file to read the expression from, without .json |
position | string | see below | Slot alias from the active layout profile |
show | bool | true | false hides the character in the resolved slot |
draw_layer | "behind_panel" | "above_panel" | pack, then slot, then behind_panel | Which plane the character draws on |
transition | transition kind | "instant" | Applies to both entering and leaving |
transition_in | transition kind | — | Overrides transition when appearing |
transition_out | transition kind | — | Overrides transition when hiding |
transition_duration | number ≥ 0 | 0 | Seconds. Takes priority over duration |
duration | number ≥ 0 | 0 | Seconds, when transition_duration is absent |
swap_mode | swap mode | from the pack | Forces how an expression change is blended |
modulate | HTML color | #ffffff | Tint |
opacity | number 0–1 | 1 | Alpha |
scale | number or [sx, sy] | 1 | Scale |
shader | string, null, or shader object | keep current | Shader for this character |
shader_params | object | {} | Uniform values, when shader is a plain name |
bind | object | — | Live property binding to flags |
interaction | object | — | Hooks fired when this command runs |
when | expression | — | Skip the whole command when the expression is falsy |
Transition kinds are instant, fade, slide_up, slide_down, slide_left, slide_right, or "" to mean “not specified”. Swap modes are auto, in_only, crossfade, out_in, cross_slide.
Which slot the character lands in
position takes a slot alias — whatever the active layout profile defines, such as left or center by default, or your own names in a custom profile. See Planes and slots.
Omit position and the engine resolves it:
- The character’s current slot, if they are already on screen.
- Otherwise the first free alias, in the profile’s
auto_selection_policyorder. - If every slot is taken, nothing happens and a warning is logged.
Showing a character also clears them from any other slot immediately, so the same character can never occupy two slots.
Visual fields are not sticky
modulate, opacity, and scale apply only to commands that mention them. Any char command that leaves one out clears the previous value for that slot.
[hana happy] # tinted
[hana sad] # tint is gone
Repeat the value on every command that should keep it, or move it into a pack drive rule instead.
The final look of a slot multiplies four sources in this order: the slot’s own modulate/scale from the layout profile, the command’s values, any matching pack drive rules, and finally bind. opacity and a bound opacity replace the alpha rather than multiplying it.
Transitions and expression changes
The command’s transition is used whenever the command names one, meaning a non-empty transition, transition_in, or transition_out, or any transition_duration or duration.
When the command says nothing about transitions:
- Changing expression on a character who is already on screen uses the pack’s
composition_transitionspolicy for that expression pair. - Anything else is instant.
swap_mode decides how the outgoing and incoming art overlap during an expression change. Without it, the pack policy chooses, and auto resolves to crossfade for a fade and in_only for a slide.
Shaders
shader value | Result |
|---|---|
| absent | Keeps whatever shader the slot already has |
null | Clears the shader |
"drop_shadow" | Loads shaders/drop_shadow.gdshader, with shader_params as uniforms |
{ "name": …, "params": …, "target": …, "pass": …, "margin": … } | Full shader object; use margin to grow the render area so effects are not clipped |
Binding to flags
bind drives a property from a flag while the character is on screen.
{
"type": "char",
"name": "hana",
"bind": {
"opacity": { "flag": "trust", "linear": [0, 0.3, 10, 1.0] },
"modulate": { "flag": "mood", "map": { "angry": "#ffbbbb" }, "default": "#ffffff" }
}
}
Bindable properties are opacity, modulate, scale, and shader:<uniform> for any uniform of the current shader. Full modes and surfaces: Binds.
Hooks
interaction.hooks fires when the command runs, not when the player clicks the character. Available events are on_show, on_hide, and on_change as a fallback for either. on_hide is chosen when show is false, otherwise on_show. Actions run in the gameplay UI context.
{ "type": "char", "name": "hana", "show": false,
"interaction": { "hooks": { "on_hide": { "type": "set_flag", "values": { "hana_gone": true } } } } }
Related
- Characters and composition — packs, drive,
char_pack/char_layout - Transitions — kinds, defaults, composition swap
- Binds — flag → property
- Planes and slots — slot aliases and layout profiles
- Shaders — per-slot shader object / clear
- Your first script — happy-path brackets
char_pack/char_layout
char_pack
Sets the active composition pack for a character. Does not redraw alone — later char commands use this pack.
{ "type": "char_pack", "name": "emily", "pack": "full" }
[char_pack emily full]
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
name | string | — | Character folder id |
pack | string | — | Pack file stem (full → full.json) |
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
name | string | — | Character id |
pack | string | — | Pack id |
when | expression | — | Skip when falsy |
Related
char_layout
Switches the active layout.characters profile (slot geometry).
{ "type": "char_layout", "profile": "closeup", "apply": "persistent", "transition": "fade", "duration": 0.35 }
[char_layout closeup fade 0.35]
[char_layout reset]
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
profile | string | — | Profile id (unless reset) |
reset | bool | false | Back to "default" |
transition | "" | "instant" | "fade" | "" → profile | Slot glide |
duration | number ≥ 0 | 0 (fade with no duration → profile or 0.3) | Seconds |
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
profile | string | — | Target profile |
reset | bool | false | Reset to default |
apply | "persistent" | "node_local" | "persistent" | Scope |
policy | "best_effort_remap" | "strict" | "best_effort_remap" | Occupancy remap |
transition | "" | "instant" | "fade" | "" → profile | Visual |
duration | number ≥ 0 | 0 | Seconds |
when | expression | — | Skip when falsy |
Only instant / fade (no slides). See Planes and slots, Transitions, layout.characters.
sprite
Places a free prop (image, rect, or polygon) on a stage layer. JSON / Mode B only (no script sugar).
{
"type": "sprite",
"id": "chair",
"value": "chair",
"layer": "bg_front",
"x": 625,
"y": 494
}
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
id | string | — | Named sprite |
value | string or null | — | Asset name; null hides |
layer | "bg_front" | "fg" | "overlay" | "overlay" | Draw band |
x / y | number | — | Position |
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
id | string | — | Named id |
layer | enum | "overlay" | Plane band |
kind | "image" | "rect" | "polygon" | "model" | "image" | Shape (model is in development) |
value | string or null | — | Asset / hide |
x / y / width / height / order | number | — | Geometry |
offset | number or array | — | Additive px |
rotation_degrees | number | — | Angle |
color / corner_radius / points | … | — | Primitives |
opacity | 0–1 | 1.0 | Alpha |
modulate / scale | color / number or [sx,sy] | — | Tint / scale |
transition / transition_in / transition_out | kind | "instant" | Show/hide |
transition_duration / duration | number ≥ 0 | 0 (fade often 0.5 in comments) | Seconds |
sfx | string | — | Loop until hide |
shader | object or null | — | Shader; uniforms go in the object’s params (a top-level shader_params is not a sprite field) |
bind / interaction / state_visual / layout | object | — | Live / hooks / states / host |
clear | layer name or "all" | — | Clear sprites |
when | expression | — | Skip when falsy |
Sprites and objects · Planes · Binds.
obj
Shows, updates, or destroys an instance of a catalog object (sprites/<id>.obj.json).
{
"type": "obj",
"object": "lamp_widget",
"instance_id": "score_lamp",
"show": true,
"x": 16,
"y": 16
}
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
instance_id | string | — | Runtime instance id |
object | string | — | Def id (required on first show) |
show | bool | — | Show / hide (true + object to create) |
x / y | number | 0 on create | Position |
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
instance_id | string | — | Instance |
object | string | — | Definition id |
show | bool | — | Visibility |
x / y | number | 0 | Position |
transition / duration | kind / number | "instant" / 0 | Show/hide |
color / opacity / sfx | … | — | Look / loop sfx |
shader / shader_params | — | — | Shader |
bind / layout / interaction | object | — | Binds / host / hooks |
apply | "persistent" | "node_local" | "persistent" | Scope |
update | object | — | Part patch |
destroy | bool | false | Destroy instance |
when | expression | — | Skip when falsy |
Def shape: Objects. Guide: Sprites and objects.
panel
Controls dialogue panel instance look, visibility, content mode, and presence policy.
{ "type": "panel", "preset": "whisper", "panel_id": "main", "apply": "persistent" }
[panel whisper]
[panel reset]
[panel clear_append]
[panel panel_id phone clear_append]
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
preset | string | — | Theme preset name |
panel_id | string | "main" | Panel instance |
text_mode / clear_append / plate | — | — | Append or plates mode, wipe history, or sticky plate name |
show | bool | — | Visibility (true on an off-route panel sticks against policy hide until cleared) |
apply | "persistent" | "node_local" | "persistent" | Scope |
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
panel_id | string | "main" | Instance |
preset | string | — | Layout preset |
show | bool | — | Visibility. Off-route true sticks against policy hide until show: false, reset, or policy_reset |
apply | "persistent" | "node_local" | "persistent" | Scope |
reset | bool | — | Reset panel (clears sticky max_height, append state, policy, shaders) |
transition / transition_in / transition_out | kind | per contract | Show/hide |
transition_duration / duration | number ≥ 0 | — | Seconds |
text_mode | "replace" | "append" | "plates" | "replace" | Content mode |
clear_append | bool | — | Wipe append/plates history only (keeps text_mode, direction, preset, policy) |
append_direction | "down" | "up" | "down" | Append / plates direction |
append_gap_lines / append_gap_px | ≥ 0 | 0 | Gaps (append_gap_lines ignored in plates) |
plate | string or null | — | Sticky plate name for text_mode plates (null / clear clears) |
max_height | number, "NN%", or "full" | theme / legacy | Sticky height ceiling for this panel |
z | number | 0 | Draw order |
mode | "adv" | — | Panel mode |
shader | object or null | omit = keep | Panel shader |
presence_mode | "talk_only" | "visible_with_char" | "always_visible" | "talk_only" | Persistent policy |
non_talking_mode | "hide" | "readonly_last_text" | "hide" | |
readonly_style_key | string | — | Readonly style |
policy_reset | bool | — | Clear policy to defaults |
linked_char_id | string | — | Explicit character link |
bind | object | — | Flag binds |
interaction | object | — | Hooks |
when | expression | — | Skip when falsy |
Node panel_* fields are one render cycle; this command is sticky unless node_local. Same-node text_mode / preset / append_* / clear_append also apply to that node’s first paint (explicit node fields win). Sticky text_mode: replace, clear_append, or reset clears that panel’s append history; a node-only text_mode: replace does not. Presets: Theme: panel. Routing: Panel routing.
labelplate
Overrides the speaker nameplate on a panel. Same dictionary may appear as node field labelplate.
{
"type": "labelplate",
"panel_id": "main",
"text": "???",
"style_key": "default",
"apply": "persistent"
}
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
text / text_key | string | — | Plate body ({@auto} expands to speaker) |
style_key | string | — | Theme labelplate.<key> |
visible | bool | — | Show / hide |
apply | "persistent" | "node_local" | "persistent" | Scope |
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
panel_id | string | "main" | Host panel |
apply | "persistent" | "node_local" | "persistent" | Sticky vs this node only |
reset | bool | — | Clear override |
mode | "inline" | "docked" | — | Placement |
visible | bool | — | Visibility |
text / text_key | string | — | Body |
prefix / suffix | string | — | Affixes |
prefix_key / suffix_key | string | — | Affixes from strings catalog |
gap | ≥ 0 | — | Spacing |
style_key | string | — | Named style |
when | expression | — | Skip when falsy |
Sticky vs node field
- Command with
apply: "persistent"sticks untilresetor new session. node_local/ node field applies for that node’s render cycle.- Style resolution: explicit
style_key→ panel presetlabelplate_style_key→ characterui.default_labelplate_style_key.
Sticky overrides: Sticky state and clear. No dedicated script keyword — JSON / Mode B / node field. Theme: labelplate. Speakers: Labelplate and speakers. when and apply.
bgm
Plays or stops looping music from audio/bgm/.
{ "type": "bgm", "value": "main_theme", "volume": 0.7, "fadein": 1.0 }
{ "type": "bgm", "value": null, "fadeout": 1.0 }
[bgm main_theme fadein 1.0 volume 0.7]
[bgm stop]
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
value | string or null | — | Track name; null / stop clears |
volume | 0–1 | 1.0 | Track ceiling |
fadein | ≥ 0 | 1.0 when playing | New track fade-in |
fadeout | ≥ 0 | 0 play; 1.0 if stopping and omitted/0 | Old track fade-out |
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
value | string or null | — | Track / stop |
volume | 0–1 | 1.0 | Ceiling |
fadein | ≥ 0 | 1.0 (play) | Fade in |
fadein_from | 0–1 | 0.0 | Start fraction of ceiling |
fadeout | ≥ 0 | see above | Fade out |
fadeout_to | 0–1 | 0.0 | End fraction |
when | expression | — | Skip when falsy |
Same track again does not restart; volume may update. Both fadein + fadeout on play → crossfade. Title default BGM uses sounds.main_bgm* (often 1.5s fades) — not these command defaults. Audio · sounds.
sfx
Plays a one-shot from audio/sfx/.
{ "type": "sfx", "value": "door_open", "volume": 0.5 }
[sfx door_open volume 0.5]
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
value | string | — | Sample name |
volume | 0–1 | 1.0 | Linear scale |
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
value | string | — | Sample; empty/null is a no-op |
volume | 0–1 | 1.0 | Scale |
when | expression | — | Skip when falsy |
typing_sfx
Overrides the typing beep sample until reset. Not stored in the story save.
{ "type": "typing_sfx", "value": "typing_alt" }
{ "type": "typing_sfx", "value": null }
[typing_sfx typing_alt]
[typing_sfx off]
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
value | string or null | — | Sample under audio/sfx/; null / off → the speaking panel’s typing.beep.sample |
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
value | string or null | — | Override / reset |
when | expression | — | Skip when falsy |
Reveal speed is separate — Typing. Beep defaults: typing.beep · Audio.
animate
Tweens or steps a property on something already on stage. Not the same as show/hide Transitions.
{
"type": "animate",
"target": "char:left",
"property": "position_x",
"to": 40,
"duration": 0.4,
"relative": true
}
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
target | string | — | bg, char:slot, sprite:id, obj:id, panel, … |
property | string | — | opacity, position_x / y, scale, modulate, shader:u, … |
to | any | — | End value |
duration | ≥ 0 | 0.5 (tween) | Seconds |
preset | string | — | shake / pulse / flash / bob or author preset |
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
id | string | auto | For stop_animate |
preset | string | — | Built-in or animations/presets.json |
mode | "tween" | "keyframes" | "frames" | inferred | Mode |
target | string | — | Target grammar |
layer | string | — | Optional layer hint for some targets |
property | string | — | Property path |
from / to | any | from = current | Ends |
duration | ≥ 0 | 0.5 | Seconds |
ease | in | out | in_out | out_in | "in_out" | Ease |
trans | linear | sine | … | "linear" | Curve |
loop | bool | "ping_pong" | false | Loop |
count | ≥ 0 | 0 (= ∞ when looping) | Iterations |
relative | bool | false | Offsets |
keyframes / frames / fps / start_frame | … | fps min 0.001 | Advanced |
intensity / decay / scale_from / scale_to / distance / color / opacity | preset extras | shake 5 / true; pulse 0.95–1.05; bob 10 | Preset knobs |
when | expression | — | Skip when falsy |
Preset duration defaults: shake 0.4, pulse 0.6, flash 0.3, bob 2.0.
Do not animate opacity while a fade transition runs on the same node. Animations · presets · stop_animate.
stop_animate
Stops running animations and restores pre-animation property values.
{ "type": "stop_animate", "id": "shake_1" }
{ "type": "stop_animate", "target": "char:left" }
{ "type": "stop_animate", "all": true }
Usually you need
| Field | Type | Effect |
|---|---|---|
id | string | Stop by animate id |
target | string | Stop all on that target |
all | bool | Stop everything |
All fields
| Field | Type | Effect |
|---|---|---|
all | bool | Stop all |
id | string | By id |
target | string | By target |
when | expression | Skip when falsy |
jump
Jumps to a node after the current command batch (first jump in the batch wins).
{ "type": "jump", "to": "after" }
[jump after]
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
to | string | — | Target node id (END / START / NEXT keywords apply) |
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
to | string | — | Target |
when | expression | — | Skip when falsy |
Linear -> / node next are not this command. Batch timing: Command flow. Expressions.
jump_if
Conditional jump: first matching case, else optional fallback.
{
"type": "jump_if",
"cases": [
{ "expr": "score > 5", "to": "good" },
{ "expr": "score > 0", "to": "ok" }
],
"else": "bad"
}
[jump_if score > 5 -> good else bad]
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
cases | array { expr, to } | — | First truthy wins |
else | string | — | Fallback node id |
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
cases | array | — | { "expr", "to" } |
else | string | — | Fallback |
when | expression | — | Skip whole command when falsy |
Expressions · jump.
call
Jumps to a subroutine node after the current command batch and pushes a return frame.
Resume is the caller’s resolved next (via next_if / next) — not the rest of the same node.
{ "type": "call", "to": "phone_ring" }
:: hallway
You're in the hallway.
[call phone_ring]
-> hallway_after
:: phone_ring
Your phone rings.
[return]
:: hallway_after
You're still in the hallway.
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
to | string | — | Subroutine entry node id |
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
to | string | — | Target |
when | expression | — | Skip when falsy |
Rules
- Prefer calling from nodes without pending choices; resume uses linear
nextonly. - Mid-node resume (text after
[call]in the same node) is not supported — put continuation on a following node. - Nesting depth is capped (64). A
jump/jump_ifwhile a call is active clears the stack (returns abandoned). - First control command in a batch wins (
call/return/jump/ story end).
Batch timing: Command flow. Pair with return.
return
Pops the call stack and jumps to the saved resume node after the current command batch.
{ "type": "return" }
[return]
Usually you need
No fields — empty object / bare [return].
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
when | expression | — | Skip when falsy |
Rules
- Empty stack → warning, no-op (stay on the current node).
- Empty resume id (caller had no valid
next) → story ends after the batch. - If a subroutine node has no
nextand no choices, Continue / advance auto-returns when the stack is non-empty (same resume as an explicit[return]). - Prefer an explicit
[return]at the end of reusable cutaways.
See call. Batch timing: Command flow.
set_flag
Sets one or more flags from a command (same semantics as node set / [flag = …]).
{ "type": "set_flag", "values": { "helped": true, "score": "= score + 1" } }
[set_flag helped = true]
(Script also uses bare [score += 1] as node set.)
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
values | object | — | Flag map; "= expr" for expressions |
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
values | object | — | Assignments |
when | expression | — | Skip when falsy |
Expressions · Binds (live property updates after flag change).
timer
Starts a named countdown. When it reaches zero, the story navigates to on_end (a node id, or NEXT / END / START). Use pause/resume/stop to control it. There is no built-in countdown display — signal urgency with dialogue, SFX, or your own UI later.
{ "type": "timer", "id": "bomb", "seconds": 30, "on_end": "explode" }
[timer id=bomb seconds=30 on_end=explode]
… later …
[timer id=bomb stop]
Usually you need
| Field | Type | Effect |
|---|---|---|
id | string | Timer name (restarting the same id replaces it) |
seconds | number > 0 | Duration (start only) |
on_end | string | Where to go when time runs out |
action | pause / resume / stop | Control an existing timer (instead of start) |
Mode A
[timer id=bomb seconds=30 on_end=explode]
[timer id=bomb pause]
[timer id=bomb resume]
[timer id=bomb stop]
Spaced keys also work: [timer id bomb seconds 30 on_end explode].
Rules
- Start shape:
id+seconds+on_end. Control shape:id+action. on_endresolves like choice targets: node id,NEXT,END, orSTART.- Timers freeze while a blocking screen is open (pause menu, etc.) and while the story is not in play. Author
pause/resumeis separate. - Remaining time is saved with the play session. New Game / return to title clears all timers.
- For a menu that times out, use
choices.timeron the choice object — not a separate wait. - To stop a timer only when a flag is set, use command meta
whenon the stop command.
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
id | string | — | Required |
seconds | number | — | Start only; must be > 0 |
on_end | string | — | Start only |
action | string | — | pause, resume, or stop |
when | expression | — | Skip when falsy |
See also
- Choices (story) —
choices.timerfor timed menus - call / jump — other navigation verbs
screen
Shows or hides a screen defined in screens.json from the story — any role (hud, modal, scene).
{ "type": "screen", "value": "minimap" }
{ "type": "screen", "value": "minimap", "show": false }
{ "type": "screen", "value": "credits", "mode": "replace" }
In script.txt:
[screen minimap]
[screen minimap hide]
[screen credits replace]
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
value | string | — | Screen id (required) |
show | bool | true | true pushes the screen; false removes that id from the stack |
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
value | string | — | Screen id from screens.json |
show | bool | true | Show or hide |
mode | "push" | "replace" | "push" | How to show. "replace" pops the current top screen, then pushes. Ignored when show is false |
when | expression | — | Skip the command when falsy (JSON / Mode B meta) |
Unknown ids error at runtime (same path as a bad screen:id button). Hiding an id that is not stacked is a no-op.
Auto HUD vs this command
game.json → session.hud_screen (default "hud") still auto-shows one HUD when a play session starts. Use screen to show extra HUDs, modals, or scene boards mid-story, or to hide them again.
Several role: "hud" screens can stay up together; each push is independent. Persistent HUDs with the same id replace themselves on push.
Related
- Screens — roles, stack, auto HUD
- Save and session —
hud_screenauto-show - Planes and slots —
roleand planes - Screen object — screens.json API
- Button / hook action
"screen:<id>"— same stack; still valid for UI chrome
screen_shader
Full-screen (or scoped) post-process shader.
{ "type": "screen_shader", "value": "glitch", "target": "game", "shader_params": { "noise_intensity": 0.2 } }
{ "type": "screen_shader", "value": null }
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
value | string or null | — | Shader stem; null clears |
target | "game" | "scene" | "all" | "game" | Coverage plane |
shader_params | object | — | Uniforms |
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
value | string or null | — | Apply / clear |
target | enum | "game" | Layers 2 / 74 / 99 |
shader_params | object | — | Uniforms |
bind | object | — | shader:… binds only |
apply | "persistent" | "node_local" | "persistent" | Scope |
reset | bool | — | Clear (alone) |
when | expression | — | Skip when falsy |
choice_style
Sets or clears the runtime button choice pack override for the play session. Does not merge into the mood theme and does not change sprite choice groups.
{ "type": "choice_style", "profile": "choice_plain" }
{ "type": "choice_style", "reset": true }
Script sugar:
[choice_style choice_plain]
[choice_style reset]
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
profile | string | — | Use themes/<profile>.json as the button pack until reset |
reset | bool | — | Clear the override → fall back to layout.choices.style |
Exactly one of profile or reset should drive the change.
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
profile | string | — | Pack stem under themes/ |
reset | bool | — | Clear sticky override |
when | expression | — | Skip the command when falsy |
Precedence
Button groups only:
- Node
choices.style - This command’s active override
game.json→layout.choices.style
Does not affect groups with render_mode: "sprite". Pack file keys: Choice packs. Node fields: Choices.
Save / session
Sticky until reset or a new session. Saved and restored with chrome (Save and session).
Related
- Styling model — packs vs mood
- Choices
theme_style— mood theme switch- Texture specs and per-state faces: Shared shapes
input
After the line finishes, show a text field, write a flag, then advance. Hosted like choices (stack/free, panel/overlay, packs). Optional pattern gates confirm.
{
"id": "ask_name",
"text": "What should I call you?",
"input": {
"flag": "player_name",
"prompt": "Your name?",
"default": "Hero",
"max_length": 24,
"pattern": "^.+$",
"style": "name_entry",
"container": { "layout": { "mode": "stack", "host": "panel" } },
"field": { "layout": { "width": 480 } },
"confirm": { "label": "OK" }
},
"next": "after_name"
}
Script (before or after the line text):
:: ask_name
What is your name?
[input player_name]
:: ask_name_rich
What is your name?
[input player_name "Your name?" default "Hero" max_length 16]
:: ask_hint
What is your name?
[input player_name placeholder "Type a name…" pattern "^.+$"]
:: ask_prefill
What is your name?
[input player_name prefill "Hero" max_length 16]
:: ask_name_again
Edit your name.
[input player_name "Your name?" default "Hero" prefill_from_flag]
:: ask_age
How old are you?
[input age "Age?" pattern "^[0-9]+$"]
Put flag/prompt in the script; put free/overlay positions and rich chrome in story.json on the same node’s input (JSON merges over the script fields). Sticky pack override: input_style.
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
flag | string | — | Destination flag (required) |
prompt | string | "" | Label above the field |
default | string | "" | Ghost text; empty Enter/OK uses this when there is no seed |
placeholder | string | "" | Ghost hint only (never submitted); used when default is empty |
prefill | string | "" | Editable text already in the field |
prefill_from_flag | bool | false | If the flag exists, seed from it (wins over prefill) |
max_length | number ≥ 1 | 32 | Clamp on submit |
pattern | string | — | Regular expression; fail keeps the field open (no flag write). Prefer ^…$ |
Ghost and seed
| Non-empty keys | Ghost in the field | Empty Enter/OK |
|---|---|---|
neither default nor placeholder | (none) | "" |
default only | default | default (when no seed) |
placeholder only | placeholder | "" |
| both | default wins | default (when no seed) |
| Seed source | Editable text | Clear field then Enter/OK |
|---|---|---|
| none | empty | see table above |
prefill | that string | "" (does not fall back to default) |
prefill_from_flag and flag exists | flag value (wins over prefill) | "" |
default, placeholder, and prefill support {flag} like dialogue lines (one pass). A flag seed from prefill_from_flag is used as stored.
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
flag | string | — | Required |
prompt | string | "" | Prompt label |
default | string | "" | Ghost + empty-Enter fallback when no seed |
placeholder | string | "" | Ghost hint only |
prefill | string | "" | Authored editable seed |
prefill_from_flag | bool | false | Seed from existing flag when present |
max_length | number ≥ 1 | 32 | Clamp |
pattern | string | — | Confirm gate |
style | string | — | Pack under themes/ |
source | string | — | Compose pack (container / field / confirm / style) |
container | object | — | Host layout (same shape as choice containers) |
field | object | — | Text-field chrome / layout |
confirm | object | — | Confirm button (label, visible, layout) |
Timing and keys
- Appears after the line finishes. If the same node also has
choices, choices are shown instead. - Enter / Confirm → submit (strip + empty→
defaultwhen no seed + clamp;patternmay reject). - Esc → submit
""(nodefaultsubstitution). Ifpatternrejects empty, stay open. Pause does not open while the field is up. - Continue / Back are blocked while the field is open.
- After Back to this node, the field shows again with the same ghost/seed rules (
prefill_from_flaguses the flag if it is still set). - Confirm label:
confirm.label(defaultOK). Hide the button withconfirm.visible: false— Enter / Esc still work.
Validation
Encode rules in pattern (e.g. ^.{3,}$, ^[0-9]+$). Invalid pattern → warn and accept. Fail keeps the field open. Use jump_if for checks regex cannot express. Keep default / prefill values pattern-safe if you use pattern.
Precedence (chrome)
Node input.style → sticky input_style → layout.text_input.style → code fallbacks (no pack file required).
At show: node input overlays layout.text_input. Packs: Text input packs.
Save
The flag is written only after a successful confirm. Sticky input_style is session chrome.
Related
input_style
Sets or clears the session text-input pack override (same role as choice_style for choices). Does not change the mood theme.
{ "type": "input_style", "profile": "name_entry" }
{ "type": "input_style", "reset": true }
Script sugar:
[input_style name_entry]
[input_style reset]
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
profile | string | — | Use themes/<profile>.json until reset |
reset | bool | — | Clear override → fall back to layout.text_input.style |
Exactly one of profile or reset should drive the change.
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
profile | string | — | Pack stem under themes/ |
reset | bool | — | Clear sticky override |
when | expression | — | Skip when falsy |
Precedence
- Node
inputstyle - This command’s active override
game.json→layout.text_input.style- Code fallbacks (no pack file required)
Pack file keys: Text input packs.
Save / session
Sticky until reset or a new session. Registered and cleared with other cumulative chrome (Save and session).
Related
theme_style
Applies a mood theme profile over themes/default.json, or resets to default.
{ "type": "theme_style", "profile": "flashback" }
{ "type": "theme_style", "reset": true }
Usually you need
| Field | Type | Default | Effect |
|---|---|---|---|
profile | string | — | Load themes/<profile>.json over default |
reset | bool | — | Back to default.json |
All fields
| Field | Type | Default | Effect |
|---|---|---|---|
profile | string | — | Profile stem |
reset | bool | — | Clear override |
when | expression | — | Skip when falsy |
Does not change choice packs — use choice_style. Styling model. Sticky; saved with chrome.
cursor
Changes the software cursor profile. Exactly one of profile / push / pop / reset.
{ "type": "cursor", "profile": "inspect" }
{ "type": "cursor", "push": "inspect" }
{ "type": "cursor", "pop": true }
{ "type": "cursor", "reset": true }
No script sugar. Hook actions: cursor:<id>, cursor:push:<id>, cursor:pop, cursor:reset.
Usually you need
| Field | Type | Effect |
|---|---|---|
profile | string | Set sticky profile |
push | string | Push profile |
pop | bool | Pop |
reset | bool | Back to game.json default |
All fields
| Field | Type | Effect |
|---|---|---|
profile | string | Set |
push | string | Push |
pop | bool | Pop |
reset | bool | Reset |
when | expression | Skip when falsy |
Push stack is not saved. Cursor · cursors.json · game cursor.
Story detail
Fields and script sugar for moments. The whole file at a glance: story/story.json map.
| Page | Job |
|---|---|
| Node fields | Every field on one moment |
| Choices | Choice menu object (items, container, style, …) |
| Command flow | Enter / mid-line / exit command batches |
| Script syntax | script.txt sugar for the same nodes |
| Flow tags | {@…} marks and body links |
| Expressions | Flag expressions (next_if, when, show_if, …) |
| Mode B | Script + story.json overlay merge |
Command object shapes inside commands arrays: Commands.
Detail pages · Adding direction · Beat as data
Node fields
Fields on one dialogue node. Script and JSON describe the same object — Story nodes.
{
"id": "park",
"talking": "hana",
"text": "Hello.",
"commands": [{ "type": "bg", "value": "street" }],
"next": "after"
}
Typical node
Common shape authors actually use (omit unused keys):
{
"id": "park",
"talking": "hana",
"label": "Hana",
"text": "Hello.{@c1} Ready?",
"set": { "met_hana": true },
"commands": [
{ "type": "bg", "value": "street", "transition": "fade", "duration": 0.5 },
{ "type": "char", "name": "hana", "position": "left" }
],
"flow_commands": [
[{ "type": "sfx", "value": "click" }]
],
"exit_commands": [
{ "type": "char", "name": "hana", "show": false }
],
"exit_delay": 0.3,
"next": "after",
"choices": {
"items": [
{ "text": "Stay", "target": "stay" },
{ "text": "Go", "target": "leave" }
]
}
}
| Zone | Keys |
|---|---|
| Identity / speech | id, talking, label, text |
| Enter | set, commands |
| Mid-line | flow_commands (+ {@cN} in text) |
| Leave | exit_commands, exit_delay |
| Branch | next, next_if, choices, input |
| Body links | text_metas |
| Timing / panel extras | auto, panel, panel_id, … (below) |
Script side-by-side: Story nodes · script.txt syntax. Batches: Command flow.
Usually you need
| Key | Type | Default | Effect |
|---|---|---|---|
id | string | auto _N | Node id (start = New Game entry; omit/_N → first moment is used) |
text | string | — | Dialogue body |
talking | string or string[] | — | Who is speaking this moment (one id or several) |
commands | array | — | Run on enter — Command flow |
next | string or null | next in file / null | Linear target |
choices | object | — | Menu — Choices |
input | object | — | Text field — input |
set | object | — | Flags on enter |
All fields
| Key | Type | Default / omit | Effect |
|---|---|---|---|
id | string | auto _N | Node id; start is the default New Game entry (Scenes and jumps) |
text | string | — | Dialogue (BBCode + {@…} + {flag}) |
label | string | — | Speaker plate override |
talking | string or array | — | Speaker id(s) |
talking_anchor | string | — | Initial focus when multi-speaker |
set | object | — | Flags on enter; "= expr" = expression |
commands | array | — | Enter commands |
flow_commands | array | — | Batches indexed by {@cN} (1-based) |
exit_commands | array | — | Run on leave |
next | string or null | auto | Linear target; NEXT / END / START keywords |
next_if | array { expr, then } | — | First truthy, else next |
choices | object | — | Choices |
input | object | — | input |
auto | number ≥ 0 | — | Auto-advance seconds ([auto N]) |
exit_delay | number ≥ 0 | 0 | Seconds after exit_commands before next node |
text_metas | object | — | Map [url=key] → action — Flow tags |
panel | string | resolve chain | Preset override |
panel_id | string | from talking → "main" | Target panel instance |
text_mode | "replace" | "append" | "plates" | "replace" | Content mode — Appending text |
append_direction | "down" | "up" | "down" | Append / plates direction |
append_gap_lines / append_gap_px | number ≥ 0 | 0 | Append gaps (append_gap_lines ignored in plates) |
plate | string | — | Plate name for text_mode plates this moment — Theme: panel → Plates |
hide_panel | bool | false | Hide the routed dialogue panel for this node (wins over routing show) |
hide_continue / hide_back | bool | false | Hide nav chrome |
driver_typing | bool | true (unless silent) | Typing driver / beep |
panel_shader | object or null | absent = clear | Per-node panel shader |
panel_presence_mode | enum | — | One-cycle presence |
panel_non_talking_mode | "hide" | "readonly_last_text" | — | One-cycle |
panel_readonly_style_key | string | — | One-cycle readonly style |
labelplate | object | — | Same keys as labelplate command |
text_ref | string | — | Strings catalog key |
text_from | string | — | Copy text from another node id |
Panel routing for presets / presence: Panel routing. panel_shader absent clears (unlike sticky panel / bg shaders).
Related
Choices (story node)
A choice menu on a dialogue node. Script > lines compile into this object. Full presentation keys live here — the opening sketch is just the idea.
"choices": {
"shuffle": false,
"show_numbers": true,
"style": "choice_button",
"items": [
{
"text": "Take the key",
"target": "vault",
"set": { "has_key": true },
"commands": [{ "type": "sfx", "value": "pickup" }]
},
{ "text": "Leave", "target": "NEXT" }
]
}
Script sugar:
> Help her. -> help_branch
> Stay back. -> skip_branch
> Continue
Per-item set / commands are Mode B (JSON) only — not Mode A > sugar.
Usually you need
| Key | Type | Default | Effect |
|---|---|---|---|
items | array | — | Required. Choice entries |
style | string or { "source" } | game / choice_style | Button pack → themes/<name>.json |
shuffle | bool | from layout.choices → false | Deterministic reshuffle. Forbidden when mode is free |
show_numbers | bool | from layout → true | Prefix N. on captions |
container | object | {} | Group layout / plate / host |
item | object | {} | Defaults merged into every item |
That covers stacked button menus. Floating sprite picks and full chrome are below.
All group keys
| Key | Type | Default | Effect |
|---|---|---|---|
items | array of objects | — | Choice entries |
container | object | {} | Group presentation — container |
item | object | {} | Defaults for each item root (layout, style, interaction, …) |
style | string or { "source" } | see precedence | Button element pack name. Empty "" falls through to game |
source | string | — | Pack compose → themes/<name>.json. Marks group pack-backed (sprite inheritance) |
shuffle | bool | layout → false | Display shuffle. Forbidden with free |
show_numbers | bool | layout → true | Number labels |
timer | object | — | Optional countdown — Timed choices |
Not allowed on the group root: gap (use container.layout.gap or layout.choices.gap), top-level layout / transition / shader / audio / interaction (those live under container).
Style name precedence (button groups): node choices.style → active choice_style → layout.choices.style. See Styling model.
Timed choices
Optional choices.timer starts when the menu is shown. A player pick cancels it. On timeout, either jump elsewhere or auto-pick an item (that item’s set / commands run).
"choices": {
"timer": { "seconds": 8, "on_end": "too_slow" },
"items": [
{ "text": "Defuse", "target": "safe" },
{ "text": "Run", "target": "hall" }
]
}
Auto-pick (uses the chosen item’s body):
"timer": { "seconds": 5, "on_end": { "pick": 0 } }
| Key | Type | Default | Effect |
|---|---|---|---|
seconds | number > 0 | — | Required |
on_end | string or { "pick": int } or { "pick_id": string } | — | Timeout jump, or auto-pick by source index / item id |
id | string | __choice_<node_id> | Timer id (saved with the session) |
Mode B only — no Mode A sugar for choices.timer. Background timers use the timer command.
Container
Allowed roots: layout, style, transition, shader, audio, interaction, source.
container.layout
| Key | Type | Default | Effect |
|---|---|---|---|
mode | "stack" | "free" | "stack" | Stack = vertical list; free = absolute item x/y |
host | "panel" | "overlay" | "panel" | Sit in the panel choice slot vs full-screen overlay |
render_mode | "button" | "sprite" | "button" | Group-wide. Sprite uses item style.sprite faces |
anchor_h | "left" | "center" | "right" | overlay: "left" | Overlay horizontal anchor |
anchor_v | "top" | "center" | "bottom" | overlay: "top" | Overlay vertical anchor |
offset_x / offset_y | number | 0 | Nudge the host |
x / y | number | — | Absolute group position when used |
width / height | number ≥ 0 | — | Group plate size only (not items) |
min_width / min_height / max_width / max_height | number ≥ 0 | — | Plate clamps |
padding | { left, top, right, bottom } | 0 | Plate padding |
gap | number ≥ 0 | from layout.choices.gap | Stack item separation. Forbidden in free |
Free vs stack
| Rule | stack | free |
|---|---|---|
| Item placement | VBox + align_h | Absolute x / y |
container.layout.gap | Allowed | Forbidden |
shuffle | Allowed | Forbidden |
Item align_h | Allowed | Forbidden |
Item x / y | Forbidden | Allowed |
container.style
| Key | Type | Effect |
|---|---|---|
source | string | Pack pull for the plate |
bg_color | color string | Plate fill |
bg_texture | texture spec | Plate texture |
border_color | color string | Border colour |
border_width | number | Border width |
corner_radius | number | Corner radius |
layout / shader / interaction | object | Nested same shapes as container roots |
style_key is rejected — use source.
container.transition
| Key | Type | Effect |
|---|---|---|
kind | transition kind | Default kind |
duration | number ≥ 0 | Seconds |
on_show / on_hide | transition kind | Enter / exit |
transition / transition_in / transition_out | transition kind | Legacy aliases |
transition_duration | number ≥ 0 | Legacy duration |
Kinds: instant, fade, slide_up, slide_down, slide_left, slide_right. Group-scoped only — not copied onto items. on_hide plays for host: "overlay"; a host: "panel" group leaves with the line change so the plate never keeps the old choice height. See Transitions.
container.audio
| Key | Type | Effect |
|---|---|---|
on_show / on_hide | string (sfx name) | Group show / hide sound |
container.interaction
| Key | Type | Effect |
|---|---|---|
focusable | bool | Group focus hint |
hooks | object | on_show, on_hide → action payloads |
hooks_override | object | Override or clear hook payloads |
Not copied onto items.
container.shader
| Key | Type | Default | Effect |
|---|---|---|---|
name | string | — | Shader stem |
params | object | {} | Uniforms |
target | "bg" | "full" | "bg" | Apply target |
pass | "direct" | "subviewport" | "subviewport" | full + direct forbidden |
margin | number or sides object | 0 | Expand host |
container.source
String pack ref; deep-merged under the container.
Items
Each entry in items:
| Key | Type | Default | Effect |
|---|---|---|---|
id | string | "" | Stable id (unique in the node); used by animate / choice_item:… |
text | string | — | Caption. Need text or text_key |
text_key | string | — | Catalog key (Localization) |
target | string or omit | NEXT | Jump target — targets |
set | object | — | Flags applied on pick before navigating (same as node set) |
commands | array | — | Commands run after set, before enter target. No jump / call / return / story input — use target to navigate. Meta when skips individual pick commands the same as enter batches |
rules | object | visible + enabled | Visibility |
layout | object | from item / game | Item layout |
style | object | from item / game | Item style |
transition | object | — | Item enter / focus / confirm FX |
shader | object | — | Same shader shape as container |
audio | object | — | on_focus / on_confirm sfx names (prefer on_confirm for a one-off pick sound) |
interaction | object | focusable true | Hooks, hotkey, hit_test |
state_visual | object | — | Per-state look / animation |
source | string | — | Per-item pack compose |
debug | any | — | Passthrough |
On pick
- Menu node
exit_commands(if any) - Item
set - Item
commands - Enter
target→ that node’sset→ its entercommands
Omit target (or "NEXT") to continue to the next node after the menu. For a loop that re-shows the same menu without re-typing a line, use a menu-only node (empty text + choices) and point target back at that node — not at a say+choices node.
Rules
| Key | Type | Effect |
|---|---|---|
show_if | expression string | Hidden when falsy |
disable_if | expression string | Shown but disabled when truthy |
Item layout
| Key | Type | Mode | Effect |
|---|---|---|---|
align_h | "left" | "center" | "right" | stack only | Horizontal align |
x / y | number | free only | Absolute position |
width / height | number ≥ 0 | both | Item size; omit in sprite for native face size |
rotation_degrees | number | both | Rotation |
Item style
| Key | Type | Effect |
|---|---|---|
source | string | Element pack pull |
text_color | color string | Caption colour (sprite packs often transparent) |
icon / hover_icon / pressed_icon / focus_icon | path string | Glyph + text; ignored when sprite theme applies |
icon_max_width | number | Icon width cap |
sprite | object | Only when group render_mode is "sprite" |
style.sprite
| Key | Type | Effect |
|---|---|---|
normal / hover / focus / pressed / disabled | texture spec | State faces |
scale_mode | "stretch" | "fit" | "cover" | "keep" | Face scaling |
Item transition
| Key | Type | Effect |
|---|---|---|
kind / duration | kind / number ≥ 0 | Defaults |
on_show / on_focus / on_confirm | transition kind | Event kinds |
Item interaction
| Key | Type | Default | Effect |
|---|---|---|---|
hotkey | string | — | Custom action; not reserved (choice_1…choice_5, choice_prev / next / cycle / confirm, dialogue_advance); unique per node |
focusable | bool | true | Focus eligibility |
hit_test | "rect" | "alpha" | "polygon" | "rect" | Hit mode; alpha / polygon need sprite render |
hooks | object | — | on_show, on_focus, on_blur, on_confirm, on_hover_enter, on_hover_exit |
hooks_override | object | — | Override or clear payloads |
Item state_visual
| Key | Type | Effect |
|---|---|---|
normal / hover / focus / pressed / disabled | entry object | Per-state look |
fallback_order | string[] | Resolve order |
Entry keys: source, shader (null to clear — unlike shader_params, which is kept when present), shader_params, animation or animations (not both). Animation keys: preset, property, to, from, duration (> 0), ease, trans, relative.
Targets
| Keyword | Resolves to |
|---|---|
NEXT | Node next, else next array node |
END | Story end (null) |
START | Pack entry node (start by default), else the first moment in the file |
Omitted target = NEXT. Resolved at load. Avoid node ids named NEXT / END / START (keyword wins; warning). Name your opening moment :: start when New Game and START should agree — Scenes and jumps.
Sprite vs button inheritance
| Condition | Inherits from layout.choices |
|---|---|
render_mode: "sprite" without choices.source | No button chrome — author container + item style.sprite on the node |
Sprite with choices.source | Pack supplies chrome; pack item is the item base |
| Either | shuffle / show_numbers / gap fallbacks still apply if unset on the node |
Example pack: examples/example-05-ui-and-polish/themes/choice_yes_no.json (free + sprite + overlay).
Related
- Guide: Choices, Free choices, State visual, Styling model
- Project defaults:
layout.choices - Pack files: Choice packs
- Runtime pack switch:
choice_style - Start path: Choices and flags
Command flow
When enter, mid-line, and exit command batches run on a dialogue node. Shared idea: Command flow (concept).
{
"id": "park",
"text": "Hello.{@c1} Nice day.",
"commands": [{ "type": "bg", "value": "street" }],
"flow_commands": [[{ "type": "sfx", "value": "click" }]],
"exit_commands": [{ "type": "char", "name": "hana", "show": false }],
"exit_delay": 0.5,
"next": "after"
}
Usually you need
| Surface | Type | Effect |
|---|---|---|
commands | array of commands | Run on enter (after set) |
flow_commands | array of batches | {@cN} runs batch N-1 while typing |
exit_commands | array of commands | Run on leave (advance or choice) |
exit_delay | number ≥ 0 | Seconds after exit_commands, before next node |
{@cN} | text tag | Trigger flow batch (1-based) |
Lifecycle order
| Step | What runs |
|---|---|
| 1 | Node set flags |
| 2 | Enter commands (top → bottom) |
| 3 | Line display / typing |
| 4 | Each {@cN} → that flow_commands batch (typing does not pause) |
| 5 | Continue / auto-advance / choice pick |
| 6 | exit_commands |
| 7 | exit_delay wait (if > 0) |
| 8 | Enter next node (next, choice target, or end) |
Leave also runs when the player picks a choice (exit, then the branch node).
Batch fields
| Key | Type | Default | Effect |
|---|---|---|---|
commands | array | — | Enter batch; omitted = no enter commands |
flow_commands | array | — | Each element is a command array (a batch) |
exit_commands | array | — | Leave batch |
exit_delay | number ≥ 0 | 0 | Wait after exit batch before advancing |
set | object | — | Flags applied on enter, before commands |
Related node timing (not command batches): auto / [auto N] — Node fields, Typing.
flow_commands shape
| Rule | Detail |
|---|---|
| Outer array | Slots indexed by {@cN} |
| Each slot | A command array (may hold several commands) |
{@c1} | flow_commands[0] |
| Missing slot | Warning; no run |
| Script sugar | Mid-line […] appends a one-command batch and inserts {@cN} |
"flow_commands": [
[{ "type": "sfx", "value": "click" }],
[
{ "type": "char", "name": "hana", "expression": "happy" },
{ "type": "sfx", "value": "whoosh" }
]
]
Text: "A.{@c1} B.{@c2} C."
Script forms
| Construct | Maps to |
|---|---|
[bg …] before spoken text | Enter commands |
[…] after text has started | New flow_commands slot + {@cN} in assembled text |
[exit: …] | Append to exit_commands |
[exit_delay N] | exit_delay |
[flag = …] | set (not a command) |
-> target | next (not a command) |
Full script map: script.txt syntax.
jump and batches
| Rule | Detail |
|---|---|
| When | After the current batch finishes |
| Winner | First jump / effective jump_if in that batch |
Enter jump | Leaves before the rest of the line plays |
Flow jump | Stops further typing on that line; transfers immediately |
Exit jump | Skips normal advance / choice target after the exit batch |
Linear next | Not a command — use only outside batches |
jump · jump_if · call · return.
Command meta when
| Field | Type | Effect |
|---|---|---|
when | expression | Skip that command if falsy / eval error; rest of batch continues |
JSON / Mode B overlay only — no [when …] in script.txt. Same expression language as next_if / expressions.
vs other conditionals
| Tool | Job |
|---|---|
Choice rules.show_if / disable_if | Menu option visibility / enable |
next_if / jump_if | Where the story goes |
Command when | Run or skip that side effect |
Cases
Conditional chrome (mutually exclusive packs):
{ "type": "choice_style", "profile": "choice_plain", "when": "chose_skin == \"plain\"" }
{ "type": "choice_style", "profile": "choice_button", "when": "chose_skin == \"textured\"" }
Conditional SFX / character / flags:
{ "type": "sfx", "value": "alarm", "when": "bomb_armed" }
{ "type": "char", "name": "rival", "show": true, "when": "rival_present" }
{ "type": "set_flag", "values": { "hint": true }, "when": "score < 3" }
Stop a timer only if it was armed (exit_commands or enter batch):
{ "type": "timer", "id": "bomb", "action": "stop", "when": "bomb_armed" }
Pick-item commands use the same meta (after item set, before target):
{
"text": "Take",
"target": "NEXT",
"commands": [
{ "type": "sfx", "value": "pickup", "when": "!quiet_mode" }
]
}
Mode B
Overlay commands are appended after script enter commands. Other batch keys follow normal overlay replace rules. Mode B.
Save note
During exit_delay (after exit commands, before the next node), a save uses the pre-exit snapshot so reload does not land mid-teardown. Save and session.
Related
- Command flow (concept) · Node fields · Flow tags
- script.txt syntax · Command API pages under sidebar Command API
script.txt syntax
Mode A stories are plain text under story/script.txt. Blank lines separate nodes. The parser builds the same objects as JSON — script is sugar, not a second runtime. Story nodes.
:: park
[bg street fade 0.5]
HANA [happy]:
Hello.
> Help. -> help
> Leave. -> END
→ roughly:
{
"id": "park",
"talking": "hana",
"text": "Hello.",
"commands": [
{ "type": "bg", "value": "street", "transition": "fade", "duration": 0.5 },
{ "type": "char", "name": "hana", "expression": "happy" }
],
"choices": {
"items": [
{ "text": "Help.", "target": "help" },
{ "text": "Leave.", "target": "END" }
]
}
}
Usually you need
| Construct | Form | Effect |
|---|---|---|
| Label | :: id | Node id (:: start = New Game entry; omit → first moment) |
| Speaker | NAME: / NAME [expr]: | Talking + optional enter char |
| Text | following lines | Dialogue (joined with {@w}) |
| Choice | > text / > text -> target | Choice item; bare > → NEXT |
| Jump | -> target / -> END | Node next |
| Flag | [flag = …] / += / -= | Node set |
| Command | [bg …], [hana left], … | Enter commands |
Full construct list
| Construct | Form | Effect |
|---|---|---|
| Blank line | empty line | Ends the current node |
| Comment | # … | Ignored |
| Label | :: id | Sets node id |
| Speaker | NAME: | talking = character id (folder name) |
| Expression | NAME [expr]: | Also queues enter char with that expression |
| Custom label | Label|NAME: | label + talking |
| Joint | NAME & NAME: / NAME [expr] & NAME (silent): | Multi-speaker talking; per-segment expr / silent |
| Silent | NAME (silent): | That speaker’s mouth drive off; on joints, only names marked (silent) |
| Text | non-special lines | Joined; speaker change inserts {@talk:id} |
| Mid-line cmds | […] after text started | flow_commands + {@cN} markers |
| Choice | > text / > text -> target | choices.items[] |
| Text input | [input flag …] | input on the node |
| Jump | -> target | next |
| End | -> END | next: null |
| Flag | [name = lit|expr] / += / -= | set |
| Branch | [if expr -> target] | next_if[] |
| Auto-advance | [auto N] | auto: N |
| Panel sugar | [panel …], [panel_id …], [hide_panel], … | Node fields or panel commands |
| Exit | [exit: …], [exit_delay N] | exit_commands / exit_delay |
| Locale helpers | [text_ref key], [text_from id] | Those node fields |
| Commands | [keyword …] / [charname …] | Enter commands (or flow if after text) |
Keyword commands: bg, bgm, sfx, panel, typing_sfx, jump, jump_if, call, return, set_flag, char_layout, char_pack, choice_style, input_style, screen, plus bare character folder names → char. ([input …] writes the node’s input field, like > writes choices.)
A line that is a whole BBCode wrap ([b]…[/b], [right]…[/right], [color=…]…[/color], …) stays as text, not a command.
Line breaks inside text: a new line in the file is a pause (the player advances). To break the line on screen without a pause, write \n in the text — [right]You\nOn my way.[/right]. Write \\n for a literal \n.
Related
Flow tags and markup
Inline markers in dialogue text. {@…} tags are processed by the typewriter; BBCode stays for rich text.
Slow.{@cps=5} Fast.{@cps}
Wait…{@w=1.0} Go.
Page break.{@p}
Fine.{@c1} Then not.
Usually you need
| Tag | Effect |
|---|---|
{@w} | Soft pause (skipped in fast / hold) |
{@w=N} | Timed pause N seconds |
{@cps=N} / {@cps} | Mid-line speed / reset |
{@cN} | Run flow batch N while typing (1-based) |
{flag} | Print a flag value |
All {@…} tags
| Tag | Effect |
|---|---|
{@w} | Pause; click resumes; skipped in fast/hold |
{@w=N} | Timed pause N seconds |
{@p} | Pause + clear page on resume (not skipped by fast) |
{@cN} | Run flow_commands[N-1] when typing reaches this mark (1-based; does not pause). Script mid-line […] inserts these for you — Command flow, Pacing |
{@cps} | Reset CPS to theme / typing default |
{@cps=N} | Override characters per second |
{@talk:id} | Switch talking / focus (parser also inserts on speaker change) |
{@phN} | Mode B: phrase from script line N (1-based) |
{@auto} | Labelplate speaker expansion |
Flag interpolate: {flag}. Escape braces with {{ / }}.
Typing defaults: Typing · typing.
BBCode
Known tags are markup (skipped by the typing clock). Common ones: b, i, u, s, color, url, code, center, right, left, img, font, font_size, cell, table, ol, ul, p, br, indent, bgcolor, fgcolor, outline_color, outline_size, opentype_features.
Unknown [word] (for example [alt]) stays literal on the panel so typing stays in sync — it is not treated as a command or as hidden markup.
To show real square brackets (or to print a tag name literally), use [lb] / [rb] → [ / ]. Example: [lb]b[rb]bold[lb]/b[rb] displays [b]bold[/b].
Clickable [url] (body metas)
Authors write [url=META]label[/url] in dialogue text. Clicks run only on the primary panel, after the line is waiting (typing finished or paused on {@w} / {@p}), and only when the press was a tap (not a scroll drag). Wheel and drag still scroll; empty body taps still Continue.
Map keys with node text_metas (Mode B / JSON; Mode A overlay on a script node):
{
"id": "inspect_desk",
"text": "See the [url=codex]Codex[/url].",
"text_metas": {
"codex": "url:https://example.com/codex",
"door": { "type": "jump", "to": "shed" }
}
}
| Resolution | Result |
|---|---|
text_metas has key META | Run that payload |
Else META is http:// or https:// | Open as url:META |
| Else | Ignore (warning once per key) |
Allowed map values: url:… strings, or a single { "type": "jump", "to": "…" } / { "type": "set_flag", "values": { … } } object. No command arrays.
| Payload | After a successful click |
|---|---|
url:… / bare http(s) | Stay waiting — Continue still needed |
{ "type": "set_flag", … } | Stay waiting |
{ "type": "jump", … } (or a screen push that leaves the beat) | Consume wait — no second body tap |
[hint] stays visual-only (no click wiring). Append history / readonly panels do not fire metas.
Related
Expressions
Flag expressions gate jumps, choice visibility, and command when. Evaluated with flag names as identifiers. Use &&, ||, ! — not and / or / not.
{ "type": "jump_if", "cases": [{ "expr": "score > 5", "to": "good" }], "else": "bad" }
[if helped -> thank_you]
> Secret. -> vault
(with item rules.show_if: "has_key")
Usually you need
| Surface | Shape |
|---|---|
| Choice visibility | rules.show_if / rules.disable_if |
| Branch | next_if[] or [if expr -> target] |
| Command skip | "when": "expr" on any command (JSON / Mode B) — Command flow |
| Flag math | "score": "= score + 1" in set / set_flag |
Where expressions appear
| Surface | Shape |
|---|---|
set / set_flag.values | Literal, or string "= score + 1" |
next_if[] | { "expr", "then" } — first truthy wins, else next |
jump_if.cases[] | { "expr", "to" } + optional else |
Choice rules.show_if / disable_if | expr string |
Command when | expr string; no script sugar |
Screen / block show_if | same grammar |
Truthiness
| Value | Result |
|---|---|
null | false |
| number | false only if 0 |
string "false" / "0" / "" | false |
string "true" / "1" / other non-empty | true |
Related
- Choices · Node fields
- Commands:
jump_if,set_flag
Mode B overlay
Mode B = story/script.txt plus story/story.json. Script is primary; JSON overlays complex nodes. Mode C is JSON only (no script).
// story/story.json
[
{
"id": "park",
"commands": [
{ "type": "sprite", "id": "chair", "value": "chair", "layer": "bg_front" }
]
}
]
Usually you need
| Rule | Behavior |
|---|---|
| Match | Overlay id (or _ref) → script node id |
| Most keys | Overlay replaces the script value |
commands | Overlay commands are appended after script commands |
| Absent keys | Script values kept |
_ref is an alternative to id in an overlay entry when you prefer not to repeat the node id; it matches a script node the same way.
Merge rules
| Rule | Behavior |
|---|---|
| Match | By id or _ref |
| Override | All overlay keys replace script values |
| Exception | commands: appended to script commands |
text + {@phN} | Substitute from script line N before erase |
| Absent keys | Script kept (talking, etc.) |
| Locale | script_<locale>.txt / story_<locale>.json |
Modes
| Mode | Files |
|---|---|
| A | script.txt only |
| B | script.txt + story.json overlay |
| C | story.json only |
Related
- Story nodes · script.txt syntax · Command flow · Node fields · Flow tags (
{@phN}) - Start path: Mode B
Screens detail
Layers, content blocks, preference binds, and button actions. The whole file at a glance: screens.json map.
| Page | Job |
|---|---|
| Screen object | One screen id: role, transitions, layers |
| Layer: background | Full-screen plate |
| Layer: menu | Button column |
| Layer: content | Card of blocks |
| Layer: sprites | UI pictures |
| Layer: chrome | Extra controls (e.g. Close) |
| Content blocks | heading, text, bind rows, history, slots, … |
| Widget bindings | Preference bind ids |
| Actions and hooks | on_click verbs and payloads |
Guide: Title, pause and settings screens · Buttons and actions.
Detail pages · screens.json map
Screen object
One entry in screens.json (plus optional top-level defaults).
{
"title": {
"role": "modal",
"layers": [
{ "type": "background", "style": { "bg_color": "#101018" } },
{ "type": "menu", "buttons": [{ "label": "Start", "interaction": { "hooks": { "on_click": "start" } } }] }
]
}
}
Usually you need
| Key | Type | Default | Effect |
|---|---|---|---|
layers | array | — | Required. Ordered layers |
role | "modal" | "hud" | "scene" | "modal" | Plane / input |
defaults (file root) | object | — | Shared transition / etc. |
All keys
| Key | Type | Default | Effect |
|---|---|---|---|
layers | array | — | Layers |
role | enum | "modal" | Plane policy |
shader | shader object | — | Screen shader |
transition / transition_in / transition_out / transition_duration / duration | — | instant / 0 | Enter/exit |
pauses_tree | bool | — | Pause game tree |
action_context | string | — | Hook allowlist context |
interaction | object | — | Screen-level hooks |
modulate / scale | hex / number or array | — | Tint / scale |
plane / persistent / input_passthrough | advanced | — | Plane overrides |
Layers: background · sprites · content · menu · chrome. Guide: Screens. Story control: screen.
Layer: background
Full-bleed plate behind other screen layers.
{
"type": "background",
"style": { "bg_color": "#101018" },
"interaction": { "hooks": { "on_click": "close" } }
}
Usually you need
| Key | Type | Effect |
|---|---|---|
type | "background" | Required |
style.bg_color / texture | — | Plate look |
interaction.hooks.on_click | action | e.g. "close" on dimmed overlays |
All keys
| Key | Type | Effect |
|---|---|---|
type | "background" | Required |
order | number | Z / locale merge |
layout / style | object | May include source |
modulate / scale | — | Tint / scale |
interaction | object | hooks (typically on_click) |
Screen object · Actions and hooks · source.
Layer: menu
Vertical column of buttons (title, pause, …).
{
"type": "menu",
"layout": { "host": "screen" },
"buttons": [
{ "label": "New game", "interaction": { "hooks": { "on_click": "new_game" } } },
{ "label": "Quit", "interaction": { "hooks": { "on_click": "quit" } } }
]
}
Usually you need
| Key | Type | Effect |
|---|---|---|
type | "menu" | Required |
buttons | array | Menu entries |
layout.host | "screen" | "panel" | Where the column sits |
All keys
| Key | Type | Effect |
|---|---|---|
type | "menu" | Required |
order | number | Z |
layout | object | host, spacing via theme |
style / source | pack | Button chrome |
buttons | array | See below |
modulate / scale | — | Layer tint |
Button keys
| Key | Type | Effect |
|---|---|---|
id | string | Locale merge / identity |
label / label_key | string | Caption |
icons / icon fields | — | Glyphs |
interaction | object | hooks.on_click … |
state_visual | object | Per-state |
show_if | expression | Visibility |
style / source | pack | Element pack |
Default chrome: buttons. Actions and hooks.
Layer: content
Card of content blocks (settings, credits, …).
{
"type": "content",
"layout": { "host": "screen" },
"blocks": [
{ "type": "heading", "text": "Settings" },
{ "bind": "master", "label": "Master" },
{ "bind": "fullscreen", "label": "Fullscreen" }
]
}
Usually you need
| Key | Type | Effect |
|---|---|---|
type | "content" | Required |
blocks | array | Content blocks |
All keys
| Key | Type | Effect |
|---|---|---|
type | "content" | Required |
order | number | Z |
layout / style | object | Card chrome (source ok). With layout.mode: "scroll", players can scroll with wheel, keys, or touch drag |
modulate / scale | — | Tint / scale |
blocks | array | Nested blocks |
Card look: Theme: card. Screen object.
Layer: sprites
Decorative or interactive sprites on a screen.
{
"type": "sprites",
"items": [
{
"id": "logo",
"kind": "image",
"image": "sprites/logo.png",
"interaction": {
"hit_test": "rect",
"hooks": { "on_click": "url:https://example.com" }
}
}
]
}
Usually you need
| Key | Type | Effect |
|---|---|---|
type | "sprites" | Required |
items | array | Sprite entries |
Layer keys
| Key | Type | Effect |
|---|---|---|
type | "sprites" | Required |
order | number | Z |
layout / style / modulate / scale | — | Layer chrome |
Item keys
| Key | Type | Default | Effect |
|---|---|---|---|
id | string | — | Locale merge key |
kind | "image" | "rect" | "polygon" | "image" | Shape |
image / points | path / [[x,y],…] | — | Kind-specific |
interaction | object | — | hooks, focusable, focus_order, hit_test, hooks_override |
state_visual | object | — | Per-state look |
modulate / scale / show_if | — | Tint, scale, condition |
Root on_click etc. are invalid — use interaction.hooks.*. hit_test: rect (default), alpha (image), polygon (polygon only).
Actions and hooks · state_visual.
Layer: chrome
Floating chrome control — usually a close button on a card corner.
{
"type": "chrome",
"id": "close",
"layout": { "host": "content", "anchor_h": "right", "anchor_v": "top" },
"button_style": "icon_close_button",
"interaction": { "hooks": { "on_click": "close" } }
}
Usually you need
| Key | Type | Effect |
|---|---|---|
type | "chrome" | Required |
interaction.hooks.on_click | action | Often "close" |
button_style | string | Pack id; empty → info_close_button |
All keys
| Key | Type | Effect |
|---|---|---|
type | "chrome" | Required |
id | string | Identity |
label / label_key | string | Caption (optional for icon-only) |
button_style | string | Named button pack |
icons / icon fields | — | Glyphs |
layout | object | host: "screen" (window, default — stays fixed while content scrolls) | "content" (overlay first content card; scrolls with layout.mode: scroll) ; anchors / offsets |
interaction | object | Hooks |
state_visual | object | Per-state |
order / style / modulate / scale | — | Layer fields |
Packs: Named button packs. Actions and hooks · Layout and host.
Content blocks
Blocks inside a content layer (or nested scroll).
"blocks": [
{ "type": "heading", "text": "Audio" },
{ "bind": "bgm", "label": "Music", "label_key": "screen.settings.bgm_volume", "min": 0, "max": 1, "step": 0.05 },
{ "bind": "locale", "label": "Language", "label_key": "screen.settings.language", "style": "locale_button", "active_style": "locale_button_active" },
{ "type": "separator" },
{
"type": "table",
"columns": [30, 70],
"rows": [
[
{ "type": "text", "text": "Go" },
{ "type": "button_row", "buttons": [{ "label": "A", "interaction": { "hooks": { "on_click": "close" } } }] }
]
]
}
]
Usually you need
| Type / shape | Role |
|---|---|
heading / text | Copy |
bind | Preference row — Widget bindings |
slider / toggle | Free controls with hooks (not prefs) |
button / button_row | Actions |
table | Multi-row percent columns |
history | Backlog log |
save_slots | Manual save/load slot grid |
separator | Rule — separator |
Common fields
| Key | Type | Effect |
|---|---|---|
type | string | Block kind (required unless bind is set) |
bind | string | Preference id — no type when set |
id | string | Locale merge / identity |
style | string pack | Element pack sugar |
source | string | Pack compose |
show_if | expression | Visibility |
interaction | object | Hooks |
state_visual | object | Per-state look |
Types
bind (preference)
| Key | Type | Effect |
|---|---|---|
bind | string | Known preference id (master, locale, …) |
label / label_key | string | When set → settings row (label | control); omit → full-width control. Labels accept BBCode ([b], [color=…], …) |
| format / min / max / step / suffix | — | Slider display (when kind is slider) |
style / active_style | pack ids | Locale chips |
Do not set type on bind blocks; only listed keys are allowed.
heading / text / link
| Key | Type | Effect |
|---|---|---|
text / text_key | string | Copy. text blocks accept BBCode; heading is plain |
separator / spacer
Theme separator supplies colour/height for separator. spacer is empty space (optional size fields via style/layout).
image
| Key | Type | Effect |
|---|---|---|
| image path / texture fields | — | Displayed image |
align | string | left | center | right |
button
| Key | Type | Effect |
|---|---|---|
label / label_key | string | Caption |
| icon fields | path | Glyphs |
interaction.hooks | object | Typically on_click |
button_row
| Key | Type | Effect |
|---|---|---|
buttons | array | Explicit row buttons (required) |
style | pack id | Default button pack |
align_h | string | left | center | right (default center) |
slider / toggle (free UI)
| Key | Type | Effect |
|---|---|---|
label / label_key | string | When set → settings-style row; omit → full width. Labels accept BBCode |
interaction.hooks | object | Require on_change and/or on_commit |
min / max / step / format fields | — | Slider only |
Only listed keys are allowed — use bind for preferences.
table
| Key | Type | Default | Effect |
|---|---|---|---|
columns | number[2..6] | — | Percent weights; must sum to 100 |
rows | array of cell arrays | — | Each row length = columns.length |
align_h | string or array | left | Cell default; array = per column |
align_v | string | center | top | center | bottom |
spacing / row_gap | int | — | Gaps |
Cells are normal blocks (text, bind unlabeled, free controls, …). No nested table or scroll. Bind/slider/toggle inside cells must omit label/label_key (put copy in an adjacent cell). Empty {} or null = spacer. Cell blocks may override align_h / align_v.
history
| Key | Type | Effect |
|---|---|---|
max_items | int | Cap |
show_speaker / speaker_on_own_line / reverse_order | bool | Layout |
empty_text / empty_text_key | string | Empty state |
colours / sizes / item_separator | — | Look |
No interaction.hooks / state_visual on history.
save_slots
Early — not final UX. The slot grid works; layout, labels, and comfort (filters, previews, per-story views) are not polished yet.
Slot grid for opt-in save / load screens. Clicks emit save_slot:N or load_slot:N.
| Key | Type | Default | Effect |
|---|---|---|---|
mode | "save" | "load" | "save" | Save writes a slot; load opens occupied compatible slots |
columns | int | 3 | Buttons per row (1–6) |
style | string | "menu_button" | Button theme prefix |
empty_label / empty_label_key | string | Slot {n} — Empty | Empty cell; {n} = slot number (literal token, not a story flag) |
occupied_label / occupied_label_key | string | Slot {n} — {node} | Occupied; {n}, {node}, and (multi-story) {story} / {story_title} |
spacing / row_gap | int | 10 | Gaps |
Slot count comes from session.save_slots (see Save and session). Empty load cells are disabled. No built-in save / load screens ship — build them yourself with this block, then point menus at screen:save / screen:load (immediate "save" / "load" stay slot 1).
scroll
| Key | Type | Effect |
|---|---|---|
blocks | array | Nested blocks (no nested scroll) |
height or height_percent | number | Window height |
min_height / max_height / separation | number | Clamps / gap |
Widget bindings · Actions and hooks · Named button packs · Theme: card · settings_layout.
Widget bindings
bind on a content block maps to a registered player preference. Control kind comes from the engine registry — do not set type on bind blocks.
{ "bind": "master", "label": "Master", "label_key": "screen.settings.master_volume", "min": 0, "max": 1, "step": 0.05 }
{ "bind": "typing_enabled", "label": "Typing sound", "label_key": "screen.settings.typing_sound" }
{ "bind": "locale", "label": "Language", "label_key": "screen.settings.language", "style": "locale_button", "active_style": "locale_button_active" }
Omit label / label_key for a full-width control. With a label, the row uses settings_layout (label | control [| value]). Labels accept BBCode ([b], [color=…], …).
Usually you need
| Bind | Control | Effect |
|---|---|---|
master / bgm / sfx | slider | Volumes |
fullscreen | toggle | Display mode |
typing_enabled | toggle | Typing beep |
locale | locale chips | Language from i18n.locales |
All bindings
| Bind | Control | Effect |
|---|---|---|
master | slider | Master volume |
bgm | slider | Music volume |
sfx | slider | SFX volume |
typing_enabled | toggle | Typing beep on/off |
fullscreen | toggle | Fullscreen |
typing_speed_scale | slider | typing.speed_scale |
auto_advance_default | slider | Auto-advance default |
font_size_scale | slider | typography.size_scale (+ commit) |
locale | locale chips | set_locale:<id> from game.i18n.locales |
Free slider / toggle blocks (with type and hooks, no bind) are for custom UI — see Content blocks.
Slider/toggle chrome: slider · toggle. Layout: settings_layout. Content blocks · Typing.
Actions and hooks
Interactive screen surfaces fire interaction.hooks with action payloads.
"interaction": {
"hooks": {
"on_click": "close",
"on_hover_enter": { "type": "ui_sfx", "name": "hover" }
}
}
Usually you need
| Payload | Example |
|---|---|
| Verb string | "close", "new_game", "screen:settings" |
| Command object | { "type": "set_flag", "values": { "seen": true } } |
| Array | [ "close", { "type": "app_action", "name": "resume" } ] |
Payload shapes
| Form | Examples |
|---|---|
| String verb | "close", "start", "continue", "save", "load", "save_slot:1", "load_slot:1", "new_game", "new_game:ep2" (early multi-story), "quit", "title", "screen:save", "screen:load", "screen:pause", "cursor:push:inspect", "url:https://…" |
| Command object | set_flag, app_action, ui_sfx, ui_transition, jump, ui_advance, cursor, ui_animate |
| Array | Mix of the above |
Events by surface
| Surface | Events |
|---|---|
| Background | on_click |
| Buttons / chrome | on_click, on_focus, on_blur, on_hover_enter / exit, on_show, on_hide |
| Slider / toggle | on_change, on_commit, focus/hover/show/hide |
| Sprites | click / hover / focus / show / hide |
hooks_override with null removes an inherited event. Shape: Interaction hooks. Concept: Buttons and actions.
Theme detail
Deep tables for packs and chrome that outgrow the overview map. Whole themes/default.json: themes map.
| Page | Job |
|---|---|
| typography | Fonts and sizes |
| colors | Fallback text / stage colours |
| panel | Dialogue-box presets |
| labelplate | Name-plate styles |
| buttons | Root menu / screen button chrome |
| Named button packs | Extra root packs / themes/<name>.json |
| separator | Horizontal rule |
| slider / toggle | Settings controls |
| scrollbar | Scroll bars |
| notification | Toast plate |
| card | Content card chrome |
| settings_layout | Labeled settings row columns |
| readonly_styles | Quiet / non-talking panel looks |
| Choice packs | Choice menu look files |
| Text input packs | Text-field chrome files |
Guide: Theme and the dialogue box · Choice and input looks.
Theme: typography
"typography": {
"font": "Jupiteroid",
"font_menu": null,
"font_dialogue": null,
"font_label": "Jupiteroid",
"size_scale": 1.0,
"sizes": {
"speaker": 45,
"dialogue": 30,
"choice": 30,
"button": 30,
"notification": 30
}
}
Usually you need
| Key | Type | Default | Effect |
|---|---|---|---|
font | string or null | — | Base font under fonts/ |
sizes.dialogue | int | — | Body size |
size_scale | number | 1.0 | Scales all sizes.* (player settings too) |
All keys
| Key | Type | Default | Effect |
|---|---|---|---|
font | string or null | — | Base; variants -Regular / -Bold / … |
font_menu | string or null | null → font | Menus / settings buttons |
font_dialogue | string or null | null → font | Dialogue + choices |
font_label | string or null | — | Speaker nameplate |
size_scale | number | 1.0 | Global multiplier |
sizes.speaker | int | — | Nameplate size |
sizes.dialogue | int | — | Body |
sizes.choice | int | — | Choice captions |
sizes.button | int | — | Screen/menu buttons |
sizes.notification | int | — | Toast text |
A panel preset may overlay a subset of these keys (font, font_dialogue, font_label, sizes.speaker / dialogue / choice) — see Panel → Typography / colors per preset. size_scale, font_menu, and menu/notification sizes stay project-global.
Colors · Panel · Theme and the dialogue box · themes map.
Theme: colors
Fallback colours when a control does not set its own.
"colors": {
"background_default": "#dfdcd7",
"speaker_text": "#c4693a",
"dialogue_text": "#e8e5e0",
"choice_text": "#c5cad5",
"notification_text": "#dfdcd7"
}
Usually you need
| Key | Type | Default (code) | Effect |
|---|---|---|---|
dialogue_text | color string | #ffffff | Body text |
speaker_text | color string | #b3bfe6 | Nameplate |
background_default | color string | #1e2347 | Empty-stage fill |
All keys
| Key | Type | Shipped / code fallback | Effect |
|---|---|---|---|
background_default | color | #dfdcd7 / #1e2347 | Fill when no background image |
speaker_text | color | #c4693a / #b3bfe6 | Nameplate |
dialogue_text | color | #e8e5e0 / #ffffff | Dialogue body |
choice_text | color | #c5cad5 / #c8d0ff | Choice caption pool default |
notification_text | color | #dfdcd7 / #e6f0ff | Toast text (+ some heading fallbacks) |
A panel preset may overlay dialogue_text and speaker_text only — see Panel → Typography / colors per preset. Choice caption colours stay on choice packs; notification_text stays project-global.
Typography · Panel · Theme and the dialogue box · themes map.
Theme: panel
panel in themes/default.json holds presets — named looks for the dialogue panel. A story picks a preset per node or per character; the theme decides what each one looks like.
{
"panel": {
"default": {
"width": "100%",
"anchor_v": "bottom",
"min_height": 200,
"padding": { "top": 20, "left": 24, "right": 24, "bottom": 24 },
"bg": "#1e2133e0",
"corner_radius": 12
},
"whisper": {
"extends": "default",
"width": "60%",
"anchor_h": "center",
"bg": "#00000099"
}
}
}
Preset names are yours. default is the only one the engine falls back to.
Usually you need
| Key | Type | Default | Effect |
|---|---|---|---|
width | "NN%", "100%", "stretch", or a number | "100%" | Panel width |
anchor_h / anchor_v | see size and position | "stretch" / "bottom" | Where the panel sits |
offset_x / offset_y | number | 0 | Nudge from the anchors (negative to move inward from right/bottom) |
min_height | number, "NN%", or "full" | 160 | Floor for height |
max_height | number, "NN%", or "full" | legacy formula | Ceiling; overflow scrolls the body text |
padding | object | see below | Inner margins |
bg | HTML color | #343a52e0 | Flat plate colour |
corner_radius | int | 0 | Rounded corners on the flat plate |
extends | string | — | Inherit another preset, then override |
scrollbar_style | string | — | Optional element pack for the body scrollbar (same overlay as screen scroll "style") |
scrollbar_layout | "default" | "reserve" | "overlay" | "default" | How the body scrollbar column is laid out — see Body scroll |
scroll_pin_duration | number ≥ 0 | 0 | Seconds to ease the body to the new append edge after settle; 0 snaps |
append_push_duration | number ≥ 0 | 0 | Seconds to ease lines when an append beat adds text. append_direction: "up": earlier lines slide down as the new top line comes in. "down": the body eases to the newest line when it scrolls. 0 snaps |
typing | object | inherit game.json | Per-preset text reveal / caret / beep — Typing per preset |
typography | object | inherit project typography | Per-preset font/sizes for body, labelplate, panel-hosted choices/input — Typography / colors per preset |
colors | object | inherit project colors | Per-preset body / speaker colours — Typography / colors per preset |
Height grows with text and choices up to max_height; past that the body scrolls. Floor + ceiling are enough for a first theme. Everything below is the full surface.
Size and position
| Key | Type | Default | Effect |
|---|---|---|---|
width | "NN%", "100%", "stretch", or a number | "100%" | Panel width. "stretch" and "100%" fill the window, minus offset_x on both sides when anchor_h is "stretch" |
min_height | number, "NN%", or "full" | 160 | Floor for the panel height |
max_height | number, "NN%", or "full" | see below | Ceiling for the panel height |
anchor_h | "left" | "center" | "right" | "stretch" | "stretch" | Horizontal anchor |
anchor_v | "top" | "center" | "bottom" | "stretch" | "bottom" | Vertical anchor |
offset_x | number | 0 | Pixels from the horizontal anchor |
offset_y | number | 0 | Pixels from the vertical anchor |
x | number | — | Absolute left edge; ignores anchor_h and offset_x |
y | number | — | Absolute top edge; ignores anchor_v and offset_y |
resize_duration | number | 0 | Seconds to ease plate height when the same preset gains/loses content (new line or choices). Preset or panel switches snap. Typing growth snaps each frame. Not show/hide — that is transition_*. 0 snaps |
scroll_pin_duration | number ≥ 0 | 0 | Seconds to ease body scroll to the append edge after the plate height is stable. 0 snaps |
append_push_duration | number ≥ 0 | 0 | Seconds to ease lines when an append beat adds text. append_direction: "up": earlier lines slide down as the new top line comes in. "down": the body eases to the newest line when it scrolls. 0 snaps |
scrollbar_style | string | — | Element style pack name for the body scrollbar; omit to use global scrollbar.* |
scrollbar_layout | "default" | "reserve" | "overlay" | "default" | Body scrollbar column layout (see Body scroll) |
typography | object | — | Overlay onto project typography for this preset (see below) |
colors | object | — | Overlay onto project colors for body / speaker only (see below) |
modulate | HTML color | #ffffff | Tint for the whole panel |
scale | number or [sx, sy] | 1 | Scale for the whole panel |
Offsets are measured from the anchored edge, so a right- or bottom-anchored panel needs a negative offset to move inward:
{ "anchor_h": "right", "offset_x": -20, "anchor_v": "bottom", "offset_y": -20 }
Height units and ceiling
min_height and max_height accept a pixel number, a percent of the window height ("40%"), or "full" (window height). Unset min_height uses 160. Unset max_height uses min(65% of the window, max(5 × min_height, 320)). If min ends up larger than max, max is raised to match (and a warning is logged).
A sticky { "type": "panel", "max_height": … } overrides the theme preset until reset — panel command.
Body scroll
Dialogue text lives in a body scroll area. When content would push the plate past max_height, the plate stops growing and the text scrolls. Name plate, continue/back with placement: "panel", and choices stay outside that scroll. Continue/back with placement: "text_end" (and the typing caret) hide while the body is scrolled away from the newest line, and return when pinned back to that edge.
Panel-hosted choices and text input (host: "panel") share the plate and shrink the body; the body stays on the newest line (the top line for append_direction: "up") until the player scrolls away. Overlay-hosted choices sit on the screen and do not take body space. There is no scroll on/off switch — the ceiling alone decides.
The body scrollbar uses global theme scrollbar.* unless the preset sets scrollbar_style to an element pack (same overlay path as screen scroll blocks — Theme: scrollbar).
scrollbar_layout controls the body scrollbar column:
| Value | Effect |
|---|---|
default (omit) | Bar appears when needed and takes width from the text when it does |
reserve | Always keep a scrollbar column (bar width + scrollbar.content_separation gap) so wrap width stays stable when the track appears; the plate keeps its preset width and the text column is narrower by that column. The track stays hidden until there is something to scroll |
overlay | Not available yet — falls back to default |
Use reserve on plates that often scroll (phone, log, fixed height). Leave normal ADV presets on default.
Spacing
| Key | Type | Default | Effect |
|---|---|---|---|
padding.top | int | 15 | Inner top margin |
padding.left | int | 20 | Inner left margin |
padding.right | int | 20 | Inner right margin |
padding.bottom | int | 20 | Inner bottom margin |
speaker_gap | int | 6 | Gap between the speaker name and the text |
choices_gap | int | 2 | Gap between the text block and the choice list |
choices_separation | int | 4 | Gap between individual choices |
Background
| Key | Type | Default | Effect |
|---|---|---|---|
bg | HTML color | #343a52e0 | Flat plate colour, used when no usable texture is set |
bg_texture | texture spec | — | Nine-slice plate image. Takes priority over bg |
bg_9slice | [left, top, right, bottom] | [0, 0, 0, 0] | Stretch margins for bg_texture |
corner_radius | int | 0 | Rounded corners on the flat plate. Has no effect once a texture is used |
A texture spec is either "panels/plate.png", { "file": "…", "modulate": "#hex" }, or a flipbook { "frames": [...], "fps": 10 } — see Pictures (texture specs).
Speaker nameplate
| Key | Type | Default | Effect |
|---|---|---|---|
labelplate_style_key | string | — | Which labelplate.<key> block styles the speaker name |
labelplate_layout | object | — | Present means the nameplate is docked to the panel edge instead of sitting inline above the text |
labelplate_layout takes anchor_h (left, center, right; default left), anchor_v (top, center, bottom; default top), offset_x, and offset_y. Offsets apply only to the anchored side, so a plate hanging above a bottom panel uses anchor_v: "top" with a negative offset_y.
The mode is resolved per node: an explicit labelplate command mode wins, otherwise the preset is docked if labelplate_layout exists and inline if it does not. Asking for docked on a preset with no labelplate_layout falls back to inline and warns.
Panel slots
slots pins extra art to the panel — most often a portrait of whoever is speaking.
{
"slots": [
{ "id": "speaker_art", "bind": "talking", "layout": { "anchor_h": "left" } },
{ "id": "corner", "object": "panel_corner_deco" }
]
}
| Key | Type | Effect |
|---|---|---|
id | string | Required, unique within the preset |
bind | "talking" | Fills the slot with the current speaker’s panel art. "talking" is the only supported binding |
object | string | Fills the slot with an object from sprites/<id>.obj.json |
layout | object | Anchors within the panel |
Each slot needs exactly one of bind or object. Which art a character supplies for a bound slot comes from ui.panel_slots in their pack.
Continue and back buttons
Each preset carries its own navigation buttons under continue_button and back_button. Both take the same keys.
| Key | Type | Default | Effect |
|---|---|---|---|
visible | bool | true for continue, false for back | Whether the button is shown |
label | string | — | Caption. An empty string gives an icon-only button |
label_key | string | — | Localisation key, used instead of label when set |
align | "left" | "center" | "right" | — | Corner placement inside the panel |
placement | "panel" | "text_end" | "panel" | "panel" parks it in a corner; "text_end" follows the last typed character |
offset_x | number | 0 | Nudge from the resolved position |
offset_y | number | 0 | Nudge from the resolved position |
style | string or object | fallback pack | Element style pack name, or an inline style object with an optional "source" |
click_sfx / hover_sfx | string or null | inherit sounds.button_* | Per-channel UI SFX override. Omit = inherit; "" or null = silent |
interaction | object | — | hooks and hooks_override for on_show, on_hide, on_hover_enter, on_hover_exit, on_focus, on_blur |
click_sfx / hover_sfx may also sit on the nested style object or pack. Button cfg keys win over style, then sounds.button_click / sounds.button_hover. If on_hover_enter already runs ui_sfx, inherited hover SFX is skipped for that button.
Any other key produces an “unknown panel button field” warning at load. There is no on_click hook: advancing and going back are wired to the buttons already.
When no style is given, the buttons fall back to the panel_continue_button_default and panel_back_button_default packs. They deliberately do not inherit the global buttons block, so a wide menu button style will not stretch your continue arrow.
Both were top-level theme keys in earlier versions. continue_button and back_button at the root of the theme file now warn and do nothing — move them inside a preset.
Inheritance with extends
extends names another preset to build on. Resolution is recursive, so a preset can extend a preset that extends another.
Merging is deep: nested objects such as continue_button or padding merge field by field rather than replacing wholesale. Setting a key to null deletes it from the inherited result.
{
"quiet": {
"extends": "default",
"padding": { "top": 8 },
"continue_button": { "visible": false },
"corner_radius": null
}
}
quiet keeps default’s left, right, and bottom padding, keeps the rest of its continue button configuration, and drops the inherited corner radius.
Typing per preset
A preset may set typing with the same keys as game.json → typing (except Settings-owned speed_scale and auto_advance_default). Set keys override; unset keys inherit through extends, then the project default. Nested objects (caret, beep, reveal_soft, …) merge key by key.
"panel": {
"phone": {
"extends": "default",
"typing": {
"reveal": "fade_line",
"caret": { "enabled": false },
"beep": { "enabled": false },
"start_sfx": { "sample": "msg_in", "volume": 0.6 }
}
},
"terminal": {
"extends": "default",
"typography": { "font": "JetBrainsMono" },
"typing": {
"cps": 45,
"caret": { "glyph": "█", "color": "#7CFF8A" },
"beep": { "sample": "key_click", "volume": 0.3 }
}
}
}
The player’s Text Speed slider still multiplies each panel’s cps. Typing Sound off silences the per-letter beep only — start_sfx / end_sfx still play (SFX bus). Full key list: Text speed and voice beeps.
Typography / colors per preset
A preset may nest typography and colors overlays. Set keys override; unset keys inherit through extends, then the project theme. Nested sizes merge key by key. null removes an inherited key.
Allowed on typography: font, font_dialogue, font_label, sizes.speaker, sizes.dialogue, sizes.choice.
Not allowed: size_scale, font_menu, sizes.button, sizes.notification (stay project / player global).
Allowed on colors: dialogue_text, speaker_text.
Not allowed: choice_text, notification_text (choice packs own caption colours; toasts stay global).
| Chrome | Font | Size | Colour |
|---|---|---|---|
| Dialogue body | font_dialogue → font | sizes.dialogue × live size_scale | dialogue_text |
| Labelplate | font_label → font | sizes.speaker × scale | speaker_text |
Choices (host: panel) | pack font else panel dialogue | pack font_size else sizes.choice × scale | pack colours |
[input] (host: panel) | same as body | pack field_font_size else sizes.dialogue × scale | pack / field colours |
Overlay-hosted choices and input keep project typography (plus their packs). Project size_scale always applies; presets must not set it.
"panel": {
"terminal": {
"extends": "default",
"typography": {
"font": "JetBrainsMono",
"sizes": { "dialogue": 18, "choice": 18, "speaker": 14 }
},
"colors": {
"dialogue_text": "#c8ffd0",
"speaker_text": "#7CFF8A"
}
}
}
Full project keys: Typography · Colors.
Plates
A preset may define named plates — per-beat plates inside the text column. Use them with text_mode: "plates" (plain append stays a single label). Keys mirror the panel plate and the shared shapes (shared shapes).
"panel": {
"phone": {
"extends": "default",
"default_plate": "them",
"plates": {
"them": {
"anchor_h": "left",
"width": "80%",
"speaker": "on_change",
"bg": "#2b3552",
"corner_radius": 14,
"padding": { "top": 8, "left": 12, "right": 12, "bottom": 8 },
"margin": { "top": 3, "bottom": 3 },
"group_gap": 6,
"transition_in": "slide_right",
"transition_duration": 0.22,
"colors": { "dialogue_text": "#e8ecff", "speaker_text": "#8aa0c8" }
},
"me": {
"extends": "them",
"anchor_h": "right",
"bg": "#3f6fd8",
"transition_in": "slide_left"
}
}
}
}
| Key | Type | Default | Effect |
|---|---|---|---|
plates | object | — | Named plate looks (same shape as nested panel presets) |
default_plate | string | — | Fallback name when the beat does not pick one |
extends | string | — | Another plate in the same preset |
anchor_h | "left" | "center" | "right" | "stretch" | "left" | Side of the text column — same values as panel anchor_h |
width | "NN%", number, or "stretch" | "80%" | Plate width; % is of the text column |
speaker | false | true | "on_change" | false | In-plate speaker header; gap uses speaker_gap |
bg / corner_radius / bg_texture / bg_9slice | as panel | — | Same plate builder as the panel |
shader | string, object, or null | — | Background effect only (target: "bg") — Shaders |
padding / margin | sides object | see example | Inner padding; outer spacing (same margin shape as choices) |
group_gap | number | 0 | Extra space when the speaker changes |
typography / colors | overlay | — | Same allowlists as the preset overlay |
transition_in | string | instant | How the live plate chrome enters on a new beat (fade, slide_up, slide_down, slide_left, slide_right, instant) |
transition_out | string | — | Validated when present; not used on fold in v1 (committed plates spawn instantly) |
transition_duration / duration | number | 0 | Enter duration in seconds; 0 or instant = no motion (duration only when transition_duration is omitted) |
transition | string | — | Fallback when transition_in is omitted (same kinds as transition_in) |
Motion is clipped inside the panel body scroll (not the whole panel). Enter can run at the same time as line typing (for example fade_line). Theme reload (F6) restyles plates without replaying enter. Preset-root transition_in / transition_out still control whole-panel show/hide only — do not reuse them for plates.
Slide kinds name the travel direction (same as stage chrome): slide_right enters from the left edge; slide_left enters from the right; slide_up from below; slide_down from above. Left-anchored chat plates usually use slide_right; right-anchored plates use slide_left.
Selection (first match): node plate → sticky [panel … plate NAME] → character ui.default_plate → preset default_plate. Clear the sticky name with plate null. Prefer character defaults for chat sides.
Panel labelplate vs plate speaker: they are independent. Hide the panel nameplate and use speaker: "on_change" for chat, or keep both. Set an explicit bg on each plate so it does not blend into the panel plate.
append_direction, append_gap_px, append_push_duration, and clear_append work with plates the same way they do with append. Use clear_append at scene boundaries on long threads.
How a preset gets chosen
For each node, the first of these that exists wins:
- The node’s
panelfield preseton apanelcommand- While the panel’s sticky mode is already a thread (
appendorplates) and this line stays in a thread mode (same-nodetext_mode replaceends the hold), the panel’s sticky layout preset (speaker switches do not yank to a character default; Back/load restore that preset) ui.default_panel_presetfrom the speaking character’s pack, if a preset by that name exists- A preset named after the resolved panel id, so panel id
sideusespanel.side default
An unknown preset name falls back to default too, warning when the panel id is not main.
Readonly appearance
When a panel stays on screen for a character who is not speaking, non_talking_mode: "readonly_last_text" keeps the last line visible in a dimmed state. Those colours come from readonly_styles.<key> at the top level of the theme, not from the preset:
| Key | Type | Effect |
|---|---|---|
text_color | HTML color | Dialogue text colour while readonly |
speaker_color | HTML color | Speaker name colour while readonly |
bg_opacity | number 0–1 | Multiplies the panel alpha |
font_scale | number 0.5–2 | Scales dialogue text from its normal size |
readonly_styles.default is used when the requested key is missing.
Related
- Planes and slots — where the panel sits relative to characters
- Panel routing — panel ids, presence, presets
- How these docs work — why this page is dense
panel— switching presets and content modes from a story
Theme: labelplate
Named styles under labelplate.<style_key> in themes/default.json. Placement and speaker text resolution: Labelplate and speakers.
"labelplate": {
"default": {
"font_color": "#ffffff",
"font_size": 20,
"bg": "#00000099",
"padding_left": 12,
"padding_right": 12,
"padding_top": 6,
"padding_bottom": 6
},
"whisper": {
"font_color": "#cccccc",
"bg": "#00000066"
}
}
Usually you need
| Key | Type | Effect |
|---|---|---|
font_color / font_size | color / number | Nameplate text |
bg | color | Flat plate |
padding_* | number | Inner margins |
Named <style_key> | object | Variant referenced by style_key |
All style keys
| Key | Type | Effect |
|---|---|---|
font_color | hex | Text colour |
font_size | number | Text size |
font | string | Font under fonts/ |
bold | bool | Bold face when available |
bg | hex | Flat fill |
bg_texture | texture spec | Image plate — Texture specs |
bg_9slice | object | Nine-slice plate (same idea as panel) |
padding_left / right / top / bottom | number | Content margins |
Resolution when a style is picked: start from labelplate.default (if any), then overlay labelplate.<style_key>.
Panel preset link
Key on panel.<preset> | Effect |
|---|---|
labelplate_style_key | Default style when node/command omits style_key |
labelplate_layout | Dock geometry (anchor_h / anchor_v / offset_*); presence implies docked when mode omitted |
Command / node field: labelplate. Panel inventory: panel.
Theme: buttons
Root menu / screen button chrome in themes/default.json. Choice menus use Choice packs, not this block (unless a pack inherits it).
"buttons": {
"min_width": 400,
"corner_radius": 8,
"border_width": 2,
"padding_left": 16,
"padding_right": 16,
"padding_top": 6,
"padding_bottom": 10,
"normal_bg": "#c5cad5b0",
"hover_bg": "#dfdcd7c0",
"pressed_bg": "#a8afc290",
"font_color": "#1e2133",
"font_hover_color": "#9a4e2a"
}
Usually you need
| Key | Type | Default | Effect |
|---|---|---|---|
normal_bg / hover_bg / pressed_bg | color | code greys | Flat fills |
font_color / font_hover_color | color | — | Label colours |
font_size | int | typography.sizes.button | Pack face size (× typography.size_scale) |
min_width | int | 0 if omit | Minimum width when > 0 |
padding_* | int | — | Content margins |
All keys
| Key | Type | Effect |
|---|---|---|
spacing | int | Menu button separation |
corner_radius | int | Uniform flat corner radius |
corner_radius_top_left / top_right / bottom_right / bottom_left | int | Per-corner radius (any present → per-corner mode; missing sides fall back to corner_radius) |
border_width | int | Uniform flat border width |
border_width_left / top / right / bottom | int | Per-side border (any present → per-side mode; missing sides fall back to border_width) |
min_width / min_height | int | Min size when > 0 |
padding_left / padding_right / padding_top / padding_bottom | int | Content margins |
normal_bg / hover_bg / pressed_bg / focus_bg / disabled_bg | color | Flat fills (focus → hover if omit) |
normal_border / hover_border / pressed_border / focus_border / disabled_border | color | Flat borders |
normal_texture / hover_texture / pressed_texture / focus_texture / disabled_texture | texture spec or null | Per-state faces |
texture_9slice | [L,T,R,B] | Nine-slice margins |
font | string | Family base under fonts/ (same as typography.font); omit → font_menu → font |
font_variant | string | regular | bold | italic | bold_italic (default regular) |
font_size | int | Face size in px × typography.size_scale; omit → typography.sizes.button |
font_color / font_hover_color / font_pressed_color / font_focus_color / font_disabled_color | color | Label colours |
font_outline_size / font_outline_color | int / color | Label outline |
font_shadow_offset_x / font_shadow_offset_y / font_shadow_color | int / color | Label shadow |
align_h or alignment | string | left | center | right (horizontal only) |
icon / hover_icon / pressed_icon / focus_icon | path | Glyphs (avoid dumping defaults on root) |
icon_max_width | int | Icon width cap |
states.<state>.padding | sides object | Per-draw-state padding (normal / hover / pressed / focus / disabled) |
Named packs (locale_button, info_close_button, hud_menu_button, …) use the same key set — Named button packs. Textures: Texture specs. Styling model · Typography.
Named button packs
Button chrome that is not the root buttons block: a file themes/<name>.json, and/or a same-named object inside themes/default.json. Same keys as buttons.
// themes/default.json (root objects)
"info_close_button": {
"min_width": 0,
"border_width": 0,
"corner_radius": 6,
"normal_bg": "#343a5280",
"hover_bg": "#5b6a82c0"
},
"locale_button": {
"min_width": 0,
"corner_radius": 6,
"padding_left": 10,
"padding_right": 10
},
"locale_button_active": {
"min_width": 0,
"normal_bg": "#c4693ad0",
"font_color": "#ffffff"
}
Usually you need
| Pack id | Role |
|---|---|
info_close_button | Default chrome close control when button_style is empty |
locale_button | Inactive locale chip |
locale_button_active | Active locale chip |
icon_close_button / hud_icon_button / hud_menu_button | Optional pack files for icon chrome / HUD glyph buttons |
How resolve works
- Start from root
buttons(unless the call skips it). - Load
themes/<prefix>.jsonif present. - Else merge root object
default.json[<prefix>]if present. - Merge inline
stylelast.
Locale switcher (screens)
{
"bind": "locale",
"label": "Language",
"label_key": "screen.settings.language",
"style": "locale_button",
"active_style": "locale_button_active"
}
Built from game.json → i18n.locales. Active locale uses active_style and is non-interactive.
Chrome close
Screen chrome with empty button_style uses info_close_button. Authored screens often set "button_style": "icon_close_button" (pack file) instead.
Keys
Identical to buttons. Styling model · Localization · Layer: chrome.
Theme: separator
Horizontal rule used by content { "type": "separator" }.
"separator": {
"height": 2,
"color": "#4a559060"
}
Usually you need
| Key | Type | Default | Effect |
|---|---|---|---|
color | color | #ffffff60 | Fill |
height | int | 2 | Thickness (px) |
All keys
| Key | Type | Default | Effect |
|---|---|---|---|
color | color | #ffffff60 | Colour |
height | int | 2 | Min height |
Screens may override per block via style (separator_color, history item_separator, …). Content blocks · themes map.
Theme: slider
Settings and content slider blocks.
"slider": {
"track_height": 8,
"grabber_size": 20,
"corner_radius": 4,
"track_color": "#4a559060",
"fill_color": "#c4693a90",
"fill_hover_color": "#d4794ab0",
"grabber_color": "#c5cad5",
"grabber_hover_color": "#dfdcd7"
}
Usually you need
| Key | Type | Default | Effect |
|---|---|---|---|
track_color / fill_color | color | — | Track and fill |
grabber_size | int | 20 | Knob diameter |
track_height | int | 8 | Track thickness |
All keys
| Key | Type | Default | Effect |
|---|---|---|---|
track_height | int | 8 | Track thickness |
corner_radius | int | 4 | Flat corners |
grabber_size | int | 20 | Generated circle size |
track_color | color | — | Flat track |
fill_color / fill_hover_color | color | — | Fill |
grabber_color / grabber_hover_color | color | — | Generated grabber |
grabber / grabber_hover | texture spec or null | null | Icon grabber |
track_texture / fill_texture / fill_hover_texture | texture spec | — | Textured track/fill |
track_9slice / fill_9slice | [L,T,R,B] | [4,4,4,4] | Nine-slice |
Widget bindings · Shared shapes · themes map.
Theme: toggle
Settings and content toggle blocks.
"toggle": {
"track_width": 48,
"track_height": 26,
"knob_size": 20,
"corner_radius": 13,
"track_off_color": "#4a559060",
"track_on_color": "#c4693a90",
"knob_color": "#dfdcd7"
}
Usually you need
| Key | Type | Effect |
|---|---|---|
track_off_color / track_on_color | color | Generated track |
knob_color | color | Generated knob |
checked + unchecked | texture specs | If both set, use PNGs instead of generated look |
All keys
| Key | Type | Default | Effect |
|---|---|---|---|
checked / unchecked | texture or null | null | PNG mode when both set |
checked_hovered / unchecked_hovered | texture or null | null | Hover swap |
icon_size | int | 0 | Fit PNGs if > 0 |
track_width / track_height | int | 48 / 26 | Generated track |
knob_size / corner_radius | int | 20 / 13 | Generated knob |
track_off_color / track_on_color | color | — | Generated |
track_off_hover_color / track_on_hover_color | color | — | Generated hover |
knob_color / knob_hover_color | color | — | Generated knob |
Theme: scrollbar
Scrollbars for content scroll blocks and history. Optional in default.json — code defaults apply if omitted.
"scrollbar": {
"width": 10,
"corner_radius": 4,
"track_color": "#4a559060",
"grabber_color": "#5b6a82",
"grabber_hover_color": "#6a75b0",
"grabber_pressed_color": "#a8b8ef"
}
Usually you need
| Key | Type | Default | Effect |
|---|---|---|---|
width | int | 10 | Bar thickness |
track_color / grabber_color | color | see above | Flat look |
All keys
| Key | Type | Default | Effect |
|---|---|---|---|
width | int | 10 | Thickness |
corner_radius | int | 4 | Corners |
content_separation | int | = width | Gap content ↔ bar |
track_color | color | #4a559060 | Track (same family as slider.track_color) |
grabber_color / grabber_hover_color / grabber_pressed_color | color | #5b6a82 / #6a75b0 / #a8b8ef | Grabber |
track_texture / grabber_texture / grabber_hover_texture / grabber_pressed_texture | texture | — | Textured |
track_9slice / grabber_9slice | array | [4,4,4,4] | Nine-slice |
Overlayable via a scroll block’s style pack. Dialogue panel presets may also set scrollbar_style to an element pack so a soft ADV plate can use a different bar than a dense settings screen, and scrollbar_layout (default / reserve) for whether the body keeps a stable scrollbar column — Theme: panel. Content blocks · themes map.
Theme: notification
Toast plate look. Hold time and copy live in notifications.
"notification": {
"bg": "#1e2133cc",
"corner_radius": 10,
"min_width": 200,
"padding_left": 24,
"padding_right": 24,
"padding_top": 10,
"padding_bottom": 12,
"offset_y": 20,
"fade_in": 0.2,
"fade_out": 0.3
}
Usually you need
| Key | Type | Default | Effect |
|---|---|---|---|
bg | color | #1e2347cc | Plate fill |
offset_y | int | 20 | Distance from top |
fade_in / fade_out | number | 0.2 / 0.3 | Motion |
All keys
| Key | Type | Default | Effect |
|---|---|---|---|
bg | color | — | Fill |
bg_texture | texture or null | null | Textured plate |
bg_9slice | [L,T,R,B] | [16,16,16,16] | Nine-slice |
corner_radius | int | 10 | Corners |
min_width | int | 200 | Width floor |
padding_left / padding_right / padding_top / padding_bottom | int | 24 / 24 / 10 / 12 | Margins |
offset_y | int | 20 | Top offset |
fade_in / fade_out | number | 0.2 / 0.3 | Tweens |
Text colour / size: colors.notification_text, typography.sizes.notification. Notifications · themes map.
Theme: card
Content-layer card chrome (settings, credits body, …).
"card": {
"max_width": 500,
"padding_left": 40,
"padding_right": 40,
"padding_top": 30,
"padding_bottom": 30,
"corner_radius": 12,
"bg": "#1e2133c0",
"spacing": 20
}
Usually you need
| Key | Type | Default | Effect |
|---|---|---|---|
bg | color | #1e234790 | Fill |
max_width | int | 500 | Width cap |
padding_* | int | — | Inner margins |
All keys
| Key | Type | Default | Effect |
|---|---|---|---|
max_width | int | 500 | Cap |
padding_left / padding_right / padding_top / padding_bottom | int | 40 / 40 / 30 / 30 | Margins |
corner_radius | int | 12 | Corners |
bg | color | — | Fill |
bg_texture / bg_9slice | texture / array | optional | Textured card |
spacing | int | 20 | Inner VBox separation |
Theme: settings_layout
Column widths and spacing for labeled preference / free-control rows (bind or slider/toggle with a label). Unlabeled controls are full width. table blocks use their own columns percents instead.
"settings_layout": {
"title_font_size": 28,
"label_min_width": 260,
"slider_min_width": 200,
"value_min_width": 60,
"item_spacing": 16
}
Usually you need
| Key | Type | Default | Effect |
|---|---|---|---|
label_min_width | int | 180 (code; samples often 260) | Label column |
slider_min_width | int | 200 | Slider column |
item_spacing | int | 16 | Row gap |
All keys
| Key | Type | Default | Effect |
|---|---|---|---|
title_font_size | int | 28 | Section heading size |
label_min_width | int | 180 | Label column |
slider_min_width | int | 200 | Slider column |
value_min_width | int | 60 | Value readout column |
item_spacing | int | 16 | Gap inside the row |
Content blocks · slider · toggle · Widget bindings · themes map.
Theme: readonly_styles
Named looks when a panel is in readonly / non-talking mode. Optional in default.json; missing keys fall back to code defaults via readonly_styles.default.
"readonly_styles": {
"default": {
"text_color": "#aaaaaa",
"speaker_color": "#888888",
"bg_opacity": 0.7,
"font_scale": 1.0
}
}
Usually you need
| Key | Type | Default | Effect |
|---|---|---|---|
text_color | color | #ffffff | Dimmed dialogue |
speaker_color | color | #b3bfe6 | Dimmed speaker |
bg_opacity | 0–1 | 1.0 | Panel alpha multiply |
All keys (per named style)
| Key | Type | Default | Effect |
|---|---|---|---|
text_color | color | #ffffff | Body |
speaker_color | color | #b3bfe6 | Nameplate |
bg_opacity | 0–1 | 1.0 | Panel modulate alpha |
font_scale | 0.5–2 | 1.0 | Scales dialogue font size |
Pick with panel / node readonly_style_key. Not an element source pack. Presence and quiet panels · How text finds a panel · themes map.
Choice packs
A choice pack is a JSON file under themes/ that skins choice buttons or supplies group/item chrome for compose (source). The pack name is the file stem: "style": "choice_button" → themes/choice_button.json.
// themes/choice_button.json — button element pack
{
"min_width": 220,
"corner_radius": 0,
"padding_left": 18,
"padding_right": 18,
"padding_top": 4,
"padding_bottom": 6,
"normal_texture": "sprites/button-normal.png",
"hover_texture": "sprites/button-hover.png",
"pressed_texture": "sprites/button-down.png",
"focus_texture": "sprites/button-hover.png",
"texture_9slice": [6, 6, 6, 6],
"font_color": "#3a2a1a",
"font_hover_color": "#6b4420",
"font_pressed_color": "#8a5a28",
"font_focus_color": "#6b4420"
}
Usually you need
| Approach | When | What you put in the file |
|---|---|---|
| Button element pack | Stacked text buttons | Textures / flat colours / fonts (table below) |
| Group compose pack | Free / sprite / overlay menus | container + item (+ optional shuffle / show_numbers) — same shapes as story choices |
| Name only | Most stories | "style": "choice_plain" in layout.choices or on the node |
How style / source resolve: Styling model. Node fields: Choices.
Two pack roles
| Role | Referenced by | Typical contents |
|---|---|---|
| Button element | layout.choices.style, node choices.style, choice_style | Flat button chrome keys (this page) |
| Group / compose | choices.source, container.source, item.source | container, item, toggles — story Choices shapes; usually omit items |
Nested source is allowed (depth capped). Locale overlays: themes/<name>_<locale>.json.
Button element pack keys
| Key | Type | Effect |
|---|---|---|
min_width | number | Minimum button width |
corner_radius | number | Rounded corners on flat faces |
border_width | number | Flat border width |
padding_left / padding_right / padding_top / padding_bottom | number | Content margins |
normal_texture / hover_texture / pressed_texture / focus_texture | texture spec or "" | Nine-slice / image faces; empty string = flat fill |
texture_9slice | [L, T, R, B] | Slice margins for textures |
normal_bg / hover_bg / pressed_bg / focus_bg | color string | Flat fills |
normal_border / hover_border / pressed_border / focus_border | color string | Flat borders |
font_color / font_hover_color / font_pressed_color / font_focus_color | color string | Caption colours |
states.<state>.padding | sides object | Per-draw-state padding override (pressed, …) |
interaction.hooks | object | Default item hooks merged under items |
icon / hover_icon / pressed_icon / focus_icon | path string | Optional glyphs (same as item style) |
icon_max_width | number | Icon width cap |
click_sfx / hover_sfx | string or null | Per-channel UI SFX for button (non-sprite) choices. Omit = sounds.button_*; "" / null = silent |
Texture specs: string path, or richer texture objects — Texture specs. Empty texture strings keep flat colour chrome (see choice_plain.json).
Same click_sfx / hover_sfx keys are allowed on item style overlays (merged with the pack). If on_hover_enter / on_confirm already includes ui_sfx, that channel’s inherited theme SFX is skipped. Sprite render_mode does not inherit button SFX — add ui_sfx hooks when you want sound.
Does not restyle screen/menu buttons (those use theme root buttons) or the dialogue panel.
Group compose pack example
// themes/choice_yes_no.json — free + sprite + overlay
{
"show_numbers": false,
"shuffle": false,
"container": {
"layout": {
"mode": "free",
"host": "overlay",
"render_mode": "sprite",
"anchor_h": "center",
"anchor_v": "center",
"width": 750,
"height": 288
},
"style": { "bg_texture": "sprites/choice_bg.png" },
"transition": { "on_show": "slide_down", "on_hide": "fade", "duration": 0.3 }
},
"item": {
"style": { "text_color": "#00000000" },
"state_visual": {
"hover": { "animation": { "property": "position_y", "to": 73, "duration": 0.08 } }
}
}
}
Node then: "choices": { "source": "choice_yes_no", "items": [ … ] } with per-item layout.x / y and style.sprite faces. Full key lists: Choices (story node).
Related
layout.choiceschoice_style- Choices · Styling model
- Examples:
examples/example-05-ui-and-polish/themes/
Text input packs
Optional element and compose packs for story input chrome. Files live under themes/<name>.json (same folder as choice packs).
No pack is required. Omit style / input_style / layout.text_input.style and the field/confirm use dialogue colours plus flat fills. Ship a pack only for a custom look.
Confirm button
Same keys as choice / nav packs (normal_bg, normal_texture, hover_texture, texture_9slice, font_color, …).
With no pack, confirm uses the theme buttons colours/corners but keeps a compact width (menu min_width is not applied) and matches the field’s font size. Named packs can set their own font_size.
Author chrome: confirm.label (default OK), confirm.visible (false hides the button; Enter / Esc still confirm).
Field
Colors inherit dialogue text by default. Optional overrides:
| Key | Role |
|---|---|
field_font_color | Typed text (caret follows unless overridden) |
field_font_size | Typed text size (default: typography.sizes.dialogue) |
field_caret_color | Caret only |
field_placeholder_color | Placeholder |
field_normal_bg / field_focus_bg / field_read_only_bg | Flat fill when no texture |
field_border | Flat border color |
field_padding_left / _right / _top / _bottom | Content margins |
field_normal_texture / field_focus_texture / field_read_only_texture | Face textures (Texture specs) |
field_texture_9slice or texture_9slice | [L, T, R, B] nine-slice margins |
Empty / omitted textures → flat colors. Focus without its own texture reuses field_normal_texture. Flipbook frames animate while the field is shown.
Example (still field + textured confirm):
{
"field_normal_texture": "ui/field_normal.png",
"field_focus_texture": { "file": "ui/field_focus.png", "modulate": "#ffffff" },
"field_texture_9slice": [12, 8, 12, 8],
"field_padding_left": 14,
"field_padding_right": 14,
"normal_texture": "ui/ok_normal.png",
"hover_texture": "ui/ok_hover.png",
"texture_9slice": [8, 8, 8, 8],
"font_color": "#e8e6e1"
}
Prompt label uses the same font color/size as dialogue (typography.sizes.dialogue) unless you only care about field chrome.
theme_style does not restyle input chrome (same rule as choice packs).
Compose pack (source)
A compose pack may include container, field, confirm, and optional style. Merged under node / layout defaults at show time.
Precedence
Node input.style → sticky input_style → layout.text_input.style → code fallbacks.
Related
Character pack detail
Pack JSON under characters/<id>/. File map: characters pack map.
| Page | Job |
|---|---|
| Pack root | display_name, ui, reserved files |
| Expressions and layers | Expression arrays, inherit, layer keys |
| Drive rules | Talking / focus mouth drive |
| Composition transitions | Expression swap fades |
Guide: What a character is · Expressions and packs.
Character pack root
Pack file: characters/<id>/<pack>.json (e.g. default.json).
{
"display_name": "Emily",
"ui": { "draw_layer": "behind_panel" },
"default": [{ "path": "base.png" }],
"drive": {},
"composition_transitions": {}
}
Usually you need
| Key | Type | Effect |
|---|---|---|
display_name | string | Speaker label |
<expression> | array or inherit object | Layers — Expressions and layers |
All root keys
| Key | Type | Effect |
|---|---|---|
display_name / display_name_key | string | Speaker label / catalog |
ui | object | default_panel_id, default_panel_preset, default_plate, default_labelplate_style_key, panel_slots, draw_layer |
drive | object | Drive rules |
composition_transitions | object | Composition transitions |
<expr> | array | inherit | Expression layers |
Reserved filenames (not packs): sprites.json, objects.json, metadata.json, profiles.json.
Expressions and layers
Expression values in a character pack: a layer array, or an inherit object.
"default": [
{ "path": "base.png" },
{ "path": "mouth.png", "drive": "!typing" },
{ "frames": ["m1.png", "m2.png"], "fps": 10, "loop": true, "drive": "typing" }
],
"happy": {
"extends": "default",
"override": [{ "layer": 1, "path": "mouth_happy.png" }]
}
Usually you need
| Form | When |
|---|---|
| Array of layers | A full expression |
{ "extends", "override" } | Variant of another expression |
Layer array entries
| Key | Type | Effect |
|---|---|---|
path | string | Image (__blank = empty) |
x / y / w / h | number | Offset / size |
modulate / scale / opacity | — | Look |
drive | string | Visibility: typing, !typing, focus, !focus, choice_open, … |
frames | string[] | Animated layer |
fps / loop / loop_count / hide_when_done / shuffle / idle_frame | — | Animation |
interval_min / interval_max | — | Frame intervals |
Inherit object
| Key | Effect |
|---|---|
extends | Base expression id |
override | [{ "layer": N, …fields }] patches |
add | Extra layers appended |
drive | Optional variant guard (mismatch → fall back via extends) |
Pack root · Drive rules · Characters.
Drive rules
Pack-root drive: predicate → rule. Runtime auto-drivers: typing, focus, choice_open (not saved).
"drive": {
"focus": { "use": "happy", "priority": 10, "scale": 1.05 },
"typing": { "modulate": "#ffffff", "priority": 5 }
}
Usually you need
| Field | Effect |
|---|---|
use | Switch to another expression while the predicate holds |
modulate / scale | Tint / size while active |
All rule fields
| Field | Type | Effect |
|---|---|---|
use | expression id | Switch variant |
modulate | hex color | Tint |
scale | number or [sx,sy] | Scale |
offset | number or [dx,dy] | Offset |
rotation_degrees | number | Rotate |
priority | number | Higher wins when several match |
transition / _in / _out | object | Tween (duration, ease, curve, optional kind) |
Matching rules: modulate/scale multiply; offset/rotation sum.
Layer drive strings only gate visibility — Expressions and layers. Distinct from flag Binds. Characters.
Composition transitions
Pack policy for same-slot expression swaps (not stage show/hide). Command transition* / swap_mode on char overrides when set.
"composition_transitions": {
"default": { "kind": "fade", "duration": 0.2, "swap_mode": "auto" },
"pairs": {
"happy->angry": { "kind": "fade", "duration": 0.4, "swap_mode": "crossfade" }
}
}
Usually you need
| Key | Effect |
|---|---|
default | Fallback { kind, duration, swap_mode } |
pairs | "from->to" overrides |
All keys
| Key | Effect |
|---|---|
default | { kind, duration, swap_mode } |
from / to | Maps expression id → payload |
pairs | "a->b", wildcards a->*, *->b, *->* |
Payload
| Field | Values |
|---|---|
kind | instant, fade, slide_up / down / left / right |
duration | seconds |
swap_mode | auto | in_only | crossfade | out_in | cross_slide |
Resolve order: exact pair → from-wildcard → to-wildcard → *->* → from map → to map → default → instant.
auto: fade→crossfade; slide→in_only; else in_only.
Transitions · char · Pack root.
Other files detail
| Page | Job | Map |
|---|---|---|
| cursors.json | Soft pointer profiles | cursors map |
| keybindings.json | Action → keys | keybindings map |
| Objects | sprites/<id>.obj.json | objects map |
| Animation presets | animations/presets.json | animations map |
| Strings catalog | strings.json / locale catalogs | — |
| extensions (game.json) | Native extension packs | game.json → extensions |
cursors.json
{
"default": "default",
"profiles": {
"default": {
"kind": "image",
"image": "sprites/pointer.png",
"hotspot": [2, 2],
"scale": 1.0
}
}
}
| Key | Effect |
|---|---|
default | Profile id |
profiles.<id> | Profile object |
Profile
| Key | Effect |
|---|---|
kind | "image" | "obj" |
image / object | Asset / obj id |
hotspot | [x, y] |
scale / modulate / opacity | Look |
shader | Sprite-shaped shader |
Locale: cursors_<locale>.json. Cursor · game cursor · cursor command.
keybindings.json
Action → array of key / mouse names. Author arrays replace the default list for that action (merge is per-action overlay).
{
"dialogue_advance": ["Space", "Enter", "MouseLeft"],
"dialogue_back": ["Backspace"],
"pause": ["Escape"]
}
Usually you need
| Action | Role |
|---|---|
dialogue_advance | Advance / confirm |
dialogue_back | Rollback |
pause | Pause screen |
Canonical actions
| Action | Role |
|---|---|
dialogue_advance | Advance / confirm |
dialogue_back | Rollback |
pause / save / load / quit | Session |
choice_1 … choice_5 | Numbered picks |
choice_prev / choice_next / choice_cycle / choice_confirm | Choice nav |
debug_* | Debug overlay |
Locale overlay: keybindings_<locale>.json. Concept: Keybindings. Save and session (dialogue_back).
Objects (*.obj.json)
Catalog definition under sprites/<id>.obj.json. Instances use the obj command.
{
"id": "lamp_widget",
"layer": "overlay",
"parts": [
{ "id": "bg", "kind": "rect", "width": 40, "height": 283, "color": "#343a52e0" },
{
"id": "ptr",
"kind": "polygon",
"points": [[20, 0], [0, 13], [20, 26]],
"bind": { "y": { "flag": "score", "map": { "5": 23 }, "default": 23 } }
}
]
}
Usually you need
| Key | Type | Effect |
|---|---|---|
id | string | Required definition id |
parts | array | Required parts |
layer | string | Default plane (overlay, …) |
Root keys
| Key | Type | Effect |
|---|---|---|
id | string | Definition id |
parts | array | Part list |
layer | string | Default draw layer |
transition / transition_in / transition_out | kind | Default show/hide |
duration / transition_duration | number | Seconds |
modulate | color | Root tint |
scale | number or [sx,sy] | Root scale |
rotation_degrees | number | Root rotation |
Parts
Kinds: image | rect | polygon | text.
Meters and HUD gadgets in the root demo are mostly rect + polygon (and bind) — you do not need sprite art for a score lamp. Use image when you have a PNG face.
Shared
| Key | Type | Effect |
|---|---|---|
id | string | Part id (for update) |
kind | enum | Shape |
x / y / width / height / order | number | Geometry / draw order |
points | [[x,y],…] | Polygon |
color / opacity / visible | — | Look |
path / value / image fields | — | Image source |
frames / fps / loop / intervals | — | Flipbook |
bind | object | Flag → property — Binds |
Text parts
| Key | Type | Effect |
|---|---|---|
text | string | Caption |
bbcode_enabled | bool | Rich text |
font_size | number | Size |
align_h | string | Align |
autowrap | bool | Wrap |
No shader / sfx patch on text parts.
Sprites and objects · obj · Transitions.
Animation presets
File: animations/presets.json (+ presets_<locale>.json).
Named presets are dictionaries of animate fields. Reference with "preset": "nudge" on an animate command.
{
"nudge": {
"property": "position_y",
"to": -8,
"duration": 0.12,
"relative": true,
"loop": "ping_pong",
"count": 2
},
"pulse_soft": {
"property": "scale",
"from": 1.0,
"to": 1.05,
"duration": 0.5,
"ease": "in_out",
"loop": "ping_pong"
}
}
Usually you need
| Key on preset | Effect |
|---|---|
property | What to tween |
to / from | Ends |
duration | Seconds |
Allowed fields
Same as animate (minus requiring type / target on the preset itself — target stays on the command): property, from, to, duration, ease, trans, loop, count, relative, keyframes, frames, fps, optional sequence[], preset knobs (intensity, …).
Built-in names (shake, pulse, flash, bob) work without this file. Animations.
Strings catalog
story/strings.json (+ strings_<locale>.json): flat key → string map for UI and keyed dialogue.
{
"game.title": "My Novel",
"screen.common.next": "Next",
"choice.yes": "Yes"
}
Usually you need
| Idea | How |
|---|---|
| Literal still works | Most fields accept plain text / label without a catalog |
| Shared copy | Set text_key / label_key / title_key / display_name_key — no sibling text / label needed |
| Standalone fallback | Keep a literal next to a key only when you want a fallback if the key is missing |
| Locale | Overlay strings_<locale>.json |
Some keys live only in the catalog: panel nav labels, notification.* toasts, and other runtime show_key strings. They do not need a matching *_key in your JSON.
Shape
| Form | Meaning |
|---|---|
| Object of string values | Catalog |
| Key with dots | Conventional namespaces (game., screen., choice.) — not enforced |
Missing keys fall back per Translations. check reports a keyed field with no literal and no catalog entry (i18n.missing_key). Used by screens, choices, notifications, window title, character display_name_key, and more.
Translations · Flow tags (literals in script stay in the script file).
extensions (game.json)
Declare native extensions. Each entry is a GDScript file under your project that the player loads at startup (and reloads on Author Refresh in author mode).
How the extension lives in the player (pause, planes, input, lifecycle, recipes): Native extensions. This page is the declaration + Host method reference.
{
"extensions": [
{
"id": "mouse_parallax",
"entry": "extensions/mouse_parallax/main.gd",
"runtime_min": "0.1.0"
}
]
}
Usually you need
| Key | Type | Default | Effect |
|---|---|---|---|
id | string | — | Unique id ([a-z0-9_]+) |
entry | path string | — | Path relative to the project root |
runtime_min | string | — | Optional player version floor; skip load if player is older |
Where this lives
| Idea | File | Path |
|---|---|---|
| Declaration | game.json | extensions (array) |
| Entry script | project tree | path in entry |
Concepts: Native extensions. Example: examples/example-06-native-extension/. Map: game.json → extensions.
Entry script
extends Node
func vn_extension_setup(host) -> void:
# required — host is the ExtensionHost API (untyped)
pass
- Cold boot loads every declared entry once.
- Author Refresh (F5) reloads extensions in author mode (frees prior instances, clears
ext.*save keys andext_*command handlers, then loads again). Production / shipped player builds still load once at boot — restart if you only ship a player without author tools. - Player Build (Filter unused assets on) ships the
entryscript and the other files in that entry’s folder (and everything underextensions/by convention). Keep companion textures/sfx next tomain.gd, or turn the filter off. - Reset your own state on story end via
host.connect_story_ended(session end does not free your Node). - Parent is a reserved
Extensionsnode; your instance is namedext_<id>.
ExtensionHost methods
| Method | Effect |
|---|---|
extension_id() -> String | This entry’s id |
runtime_version() -> String | Player / kit version |
get_plane(plane_id: String) -> CanvasLayer | Named draw plane, or null |
register_save(key, save_fn, restore_fn) | Registers under ext.<id>.<key> |
register_command(verb, handler) | Registers story command ext_<id>_<verb> |
get_flag(name) -> Variant | Read a story flag |
set_flag(name, value) | Write a story flag (shared with the story) |
connect_node_entered(cb) | Story entered a node |
connect_flag_changed(cb) | A flag changed |
connect_story_ended(cb) | Story ended |
set_input_blocker(enabled) | Block dialogue advance / choice confirm (not tree pause) |
get_phase() -> String | "title" / "playing" / "exit_commands" / "story_ending" |
jump(node_id) | Request a story jump (same path as jump command) |
resume() | Clear the input blocker only |
notify(message) | Toast above modal screens |
push_screen(screen_id) -> bool | Push an id from screens.json, or false |
pop_screen() | Pop the current modal screen if showing |
log(message) | print with [ext:<id>] prefix |
warn(message) | Warning with the same prefix |
Custom commands
Call host.register_command("burst", handler) to handle Mode A / JSON commands whose type is ext_<id>_burst (host builds the type string; verb must match [a-z0-9_]+). Schema/check allows that type only when id is declared in game.json → extensions[].id. Remaining Mode A tokens land in "args" (and key=value tokens are also copied onto the command object). For structured fields prefer JSON / Mode B.
Plane ids
bg, scene_ui, chars, panel, above_panel, overlay, hud, notify, modal, cursor, transition.
Draw order and title/pause stacking: Native extensions · Screens.
Save order
Core systems register first; extensions register afterward (load order). Restore runs in registration order, so extension state restores after core state. F5 unregisters all ext.* keys before reload.
Using several extensions
There is no limit on how many entries you declare.
| Rule | Meaning |
|---|---|
| Declared array order | Load order and save-registration order |
Unique id | Schema rejects duplicates |
| Save keys | Always ext.<id>.… — isolated per extension |
| Shared planes | Children stack in load order; use z_index or reorder the array |
| Flags | Not namespaced — shared with the story. Prefer ext_<id>_* for private state |
| One failure | A skipped entry (missing file, bad script, runtime_min) does not block later entries |
| Cooperation | No inter-extension handles — use flags |
Failure modes
| Problem | Result |
|---|---|
Missing / empty entry file | Skip + error log |
| GDScript parse / reload error | Skip + error log |
Missing vn_extension_setup or not a Node | Skip + error log |
Duplicate id | Keep first; skip later |
runtime_min newer than player | Skip + error log |
| Author mode | After title is up: toast above the title listing loaded id(s) (“unrestricted code”) |
| Production / shipped | Silent load |
Related
Native extensions · game.json map · Screens · Build and hand off
Shared shapes
Some settings look the same wherever they appear; others share only a name. Each shape below is tagged:
- shared — one shape, one meaning, everywhere.
- per-surface — same name, different body. The table says which surface accepts what.
| Shape | Tag |
|---|---|
| Fades and slides | Shared vocabulary |
| Pictures (texture specs) | Per-surface |
| Position on screen | Per-surface (host varies) |
| Conditions and “only when” | Split — three different shapes |
Sticky vs this moment (apply) | Shared |
Live links to flags (bind) | Shared (surface list varies) |
| Click, hover, focus (hooks) | Shared vocabulary, per-surface events |
Looks per state (state_visual) | Shared |
| Layering one look on another | Shared |
| Effects (shaders) | Per-surface |
modulate and shader_params | Per-surface |
Fades and slides
Shared vocabulary. Kinds: instant, fade, slide_up / down / left / right.
| Field | Meaning |
|---|---|
transition | Same kind for enter and leave |
transition_in / transition_out | Separate enter / leave |
duration / transition_duration | Seconds (0 = instant) |
Used on backgrounds, characters, sprites, objects, panels, screens. The default differs by surface (e.g. bg fades by default; char is instant), so check the command page.
One exception: char_layout accepts only "" / instant / fade (no slides).
→ Transitions · character expression swaps: Expression fades
Pictures (texture specs)
Per-surface. Two shapes share the word “picture”:
Chrome faces — panel plates, buttons, choice faces, text-input fields, sliders:
| Form | Meaning |
|---|---|
| string path | Still picture in the project |
{ "file", "modulate"? } | Still + optional tint |
{ "frames", "fps"?, "modulate"? } | Flipbook |
"" | Flat / no image (where allowed) |
Character pack layers — one picture inside an expression — are a different shape: path (not file), plus frames, fps, drive, x / y / w / h, and per-layer modulate / scale / opacity. A layer that uses file is dropped — use path. → Expressions and layers
Where "" is allowed: chrome faces ("" = flat / no image); source ("" = keep empty); char.transition ("" = not specified); an SFX name ("" = silent). It is not a universal value — check the field.
→ Theme and the dialogue box · Choice and input looks
Position on screen
Per-surface. Either absolute x / y, or anchor_h / anchor_v plus offset_x / offset_y. Optional width / height, min/max, padding.
Host — where the surface offers it — differs: dock to the window (screen), beside the dialogue box (panel), or overlay for free choice menus.
→ Places on stage · Gameplay HUD · Sprites and objects
Conditions and “only when”
Split — three unrelated shapes share only the word “condition”:
| Shape | Where | Effect |
|---|---|---|
when | Commands (data / direction) | Skip this command if the expression is falsy |
show_if / disable_if | Screen buttons / choice rules | Hide or disable until the condition is true |
jump_if / [if …] / next_if | Story | Branch on flags |
→ Reacting to flags live · Choices and memory · Expressions
Sticky vs this moment (apply)
Shared.
| Value | Effect |
|---|---|
persistent (usual default) | Stays until reset, override, or new game |
node_local | Reverts after this moment |
Used on panel, labelplate, obj, and similar command families.
→ Sticky state and clear · Reacting to flags live
Live links to flags (bind)
Shared — the shape is the same; the surfaces that offer it differ.
"bind": {
"opacity": { "flag": "lamp_on", "map": { "true": 1, "false": 0.2 } }
}
Surfaces: sprites, objects, characters, backgrounds, panels, and others that list bind keys in their command page.
| Mode | Meaning |
|---|---|
map | Discrete flag value → property |
linear | Numeric range remap |
direct | Pass the flag value through |
→ Reacting to flags live · character look: Expressions and packs
Click, hover, focus (hooks)
Shared vocabulary, per-surface events. The shape is the same; which events exist depends on the surface (screen button vs sprite vs choice item).
"interaction": {
"focusable": true,
"hit_test": "rect",
"hooks": {
"on_click": "screen:pause"
},
"hooks_override": {}
}
| Key | Meaning |
|---|---|
hooks | Event → string verb, command object, or array of those |
hooks_override | Override / clear inherited events (null removes) |
focusable / focus_order | Focus |
hit_test | rect | alpha | polygon |
A menu button carries its action here — interaction.hooks.on_click. Verb inventory: Actions and hooks.
→ Buttons and actions · Sprites and objects
Looks per state (state_visual)
Shared. Per-interaction-state faces: normal, hover, focus, pressed, disabled, optional fallback_order.
Each state may set textures / modulate, source pack compose, shader_params, or a short animation / animations block (not both). shader on a state is clear-only (null to clear) — shader_params is kept when present.
→ Choice and input looks · Buttons and actions
Layering one look on another
Shared.
| Tool | Meaning |
|---|---|
"extends" | Merge keys (panel presets) or layers (character expressions) — different tools |
"style": "name" | Sugar for { "source": "name" } |
"source": "name" | Load themes/<name>.json (+ locale), deep-merge local keys |
path with / | Under the themes tree |
Nested source up to depth 3. Merge: missing = inherit; null = delete; "" = keep empty; present = override.
→ Reusing settings · Choice packs
Effects (shaders)
Per-surface. Accepts a bare string (a stem under shaders/) or an object, and null clears — but which forms and target values each surface allows differs:
| Surface | Accepts | target allowed | Default |
|---|---|---|---|
char | string or object | full | full |
bg / stage | string or object | full | full |
sprite | object only | full | full |
obj | object only | full | full |
panel | object only | bg | full | bg |
| screens / choices | object only | bg | full | bg |
| theme plate (panel face) | string or object | bg | bg |
Only char / bg / stage (and theme plates) take a bare string; the rest need the object.
Object keys:
| Key | Meaning |
|---|---|
name | Stem under shaders/ (required) |
params | Uniforms |
target | bg | full (see table) |
pass | direct | subviewport (default subviewport) |
margin | Number or { left, top, right, bottom } — needs a model background on bg / stage |
panel and screens forbid full + direct.
Where an effect sits: one shader per slot / instance — the whole composited result — never per layer or per part:
- Character: one shader over the whole slot (all layers composited). Not per layer.
- Object: one shader over the whole instance; text parts take no shader.
- Sprite / background: one each.
Full-screen sticky: screen_shader.
→ Shaders
modulate and shader_params
Per-surface. modulate is the most inconsistent shared-looking key:
| Surface | modulate | Type | Scope |
|---|---|---|---|
| character layer | yes | hex string | per layer |
| character slot | yes | color | whole slot |
sprite | yes | color | whole sprite |
bg / stage | yes | hex string | the plate |
obj | yes | hex | whole instance |
panel | no | — | — |
state_visual entry | yes | per state | per state |
shader_params (uniforms) is a sibling of shader on char / bg / stage / obj; sprite and panel have no shader_params — put uniforms in the shader object’s params.
Next
Words used in these docs
These docs try to use plain words. Where the player, error messages or older pages use a technical name, it is listed here too.
| In these docs | Also called | Meaning |
|---|---|---|
| Moment | node, beat | One click-through step of the story: what happens, then what is said |
| Talking | talking | Who is speaking this moment — one id or several (may differ from who is on screen) |
| Scene | label | A named moment you can jump to (:: park) |
| Stage direction | command | A bracket line that changes the scene ([bg street]) |
| The short way to write it | script sugar, Mode A syntax | Writing a command as a bracket line instead of data |
| Script only | Mode A | The whole story in story/script.txt |
| Script + direction file | Mode B | Dialogue in the script, extra direction in story/story.json |
| All in story.json | Mode C | Every moment written as data |
| Flag | variable, var | Something the story remembers (helped, score) |
| Native extension | extensions[] | Custom code the player loads from game.json — Native extensions |
| Session | playthrough | New Game / Continue / Load until title — Save, Back and story end |
| Locale overlay | *_uk.json, script_en.txt, … | Language file next to a base file — Translations |
| Place on stage | slot | Where a character stands (left, center, right) — Planes and slots · Places on stage |
| Draw stack | plane | Fixed layer that decides what sits in front — Planes and slots |
| Arrangement | layout profile | A named set of places |
| Layered character | pack, composition | A character built from several pictures — Expressions and packs |
| Expression swap fade | composition_transitions | Soft blend when the look changes on stage — Expression fades |
| Talking and focus reactions | drive | Pictures or effects that change while a character speaks or is in focus — Talking and focus |
| Composition layer | pack layer | One stacked picture inside a character expression |
| Draw band (props) | sprite/obj layer | bg_front / fg / overlay — Sprites and objects |
| Silent line | (silent), driver_typing: false | Speaker line with typing mouth / beep off — Lines and speakers |
| Dialogue box | panel | The box the text appears in — What a panel is |
| Panel instance | panel id | Which box on screen (main, side, …) — How text finds a panel |
| Panel look | panel preset | Named look under panel.<name> in the theme — How a panel looks |
| Stacked panel lines | text_mode append | Keep earlier text and add the next moment’s text — Appending text |
| Panel height ceiling | max_height | How tall the plate may grow before body text scrolls — How a panel looks |
| Name plate | labelplate | The speaker’s name box — Name plate |
| Theme mood | theme_style | Overlay theme file for a stretch of play — Theme and the dialogue box |
| Interface | chrome | Menus, buttons and boxes around the story |
| Screen | screen | A full menu or overlay: title, pause, settings — Title, pause and settings screens |
| In-game bar | HUD | A small screen shown during play — Gameplay HUD |
| Stays until you change it | sticky | A setting that carries on to later moments |
| Live link to a flag | bind | A value on screen that follows a flag by itself |
| Look | style, pack, theme | How something is drawn |
| Reuse / build on | inheritance, extends | Start from an existing look or expression; change only what differs — kinds differ: Reusing settings |
| Pack look | source, style | Load a theme pack onto a button, choice or name-entry field |
| Expression variant | character extends / override / add | One expression built from another’s layers |
| Layer (front / back) | plane | Which things draw in front of which |
| Shared shape | contract | A settings shape used in many places |
| Effect | shader | A visual filter on a picture or the whole screen — Shaders |
| Show/hide blend | transition | How something appears or leaves — Transitions |
| Tween / preset motion | animate | Move or pulse something already on stage — Animation |
| Soft pointer | cursor | Custom mouse look — Cursor |
| Language overlay | locale, i18n | Extra language files and Settings switch — Translations |
| Author player | author mode | The player you write and test in — How you work |
| Released game | player build | What you give to readers — Build and hand off |
| Boot brand | splash | Loading / boot look — Title, icon, splash |