Writing a game
On this page
A Gozen game is data. Run the directory with gozen run; no Go source or compiler is required. Everything a player sees comes from YAML and the pictures beside it. This page is the map. The Schema has every field, and the Cookbook has short recipes for common tasks, such as a shop, a boss fight or a locked door.
The game directory #
game/
├── game.yaml # the game: title, start, screen, fonts, battle rules, text
├── data/ # definitions; every .yaml file here is read and merged
│ ├── world.yaml
│ └── ...
└── assets/ # pictures, fonts, music and sounds, referred to by file name
game.yaml is a Game. Each file in data/ is a DataFile and may hold any of its sections, so split by area (town.yaml, cave.yaml) or by kind (enemies.yaml, skills.yaml) as you like. Every ID lives in one namespace per section, and a duplicate is an error.
Start a new game.yaml with schema_version: 11, as the bundled templates do. To move an older game to schema 11, change the header and write enabled: false on every tactic row that omitted enabled, because an omitted switch changed from false to true. Before v1.0.0 the formats may change between versions and gozen has no migration command.
From version 1 on, a number you leave out and a 0 you write are different, and the game id (used for the save directory) is required. Version 2 adds the commands and set targets marked “version 2” below; a version 1 game reads the same as before.
What goes where #
| Section | Holds | Example |
|---|---|---|
classes | stats by level, skills learned by level, skill trees | mhp: "20 + level * 6" |
actors | the party members: name, class, faces, AI | hero: {name: Hero, class: fighter} |
skills, items, states | what can be used and what it does | effects: [{damage: a.atk}] |
enemies, troops | foes and the groups they come in | slimes: {members: [slime, slime]} |
ai | shared tactics, by ID | [{use: heal, when: "self.hp_rate < 0.3"}] |
locations | places: people, actions, exits, encounters, dungeons | see below |
events | named command lists, called or triggered | trigger: enter |
shops, music | goods; songs and moods | |
profiles | the character book: a page per character | mira: {title: Courier, text: ...} |
enemies with ui.bestiary | the monster book: a page per enemy | mote: {lore: "Lives near the river stones."} |
Combat games need attack and guard, the battle commands; game.yaml’s battle: can point them elsewhere. A narrative game can use actors without classes and needs no dummy party or combat skills.
Places #
The player is always at a location. A location shows its people, its actions and its exits as a menu:
locations:
village:
name: Village
background: village.jpg
people:
- name: Elder
image: elder.png
talk:
- say: {who: Elder, text: Slimes have been seen in the field.}
- set: {flag.asked: true}
actions:
- label: Rest
do: [{heal: all}, {say: You feel rested.}]
exits:
- {to: field, label: Go to the field, when: flag.asked}
Anything with when appears only while its expression holds. on_enter runs on arrival; safe: true lets the player save from the menu there; encounters sets their rate and troops.
Maps and painted scenes #
A location can also be shown as a map or as a painted scene (schema_version: 3). Its people, actions and exits stay the same; these only say where things are.
locations:
town:
name: Town
map:
picture: maps/town.jpg # points are in the picture's pixels
points:
square: {at: [960, 700], to: plaza, start: true}
shop: {at: [600, 520], to: shop}
gate: {at: [1500, 900], to: gate, when: "item.key > 0", locked: The gate is shut.}
sign: {at: [1120, 460], label: Signpost, do: [{say: "WELL, this way."}], once: true}
roads:
- {between: [square, shop], via: [[800, 650]], steps: 2}
- {between: [square, gate], encounters: {rate: "0.2", troops: [{troop: wolves}]}}
- {between: [square, sign]}
plaza:
name: Plaza
background: bg/plaza.jpg # the painting, from a fixed camera
stage:
depth: bg/plaza_depth.png # 16-bit gray, near is light (optional)
scale: [{at: [960, 1060], height: 190}, {at: [1250, 420], height: 78}]
walk: [[[1000, 330], [1480, 330], [1700, 760], [1500, 1080], [430, 1080], [960, 480]]]
blocks: [[[1150, 360], [1370, 360], [1380, 410], [1150, 410]]]
fronts: [{area: [[1222, 50], [1305, 50], [1385, 405], [1150, 405]], base: 392}]
spots:
start: {at: [960, 980], facing: north}
alley: {at: [1040, 470], facing: south}
exits: [{to: shop, area: [[1000, 330], [1100, 330], [1100, 360], [1000, 360]]}]
people:
- {id: ox, at: [1180, 520], facing: west, doll: {hair: bald, parts: [{kind: apron, color: "#2f6fb8"}]}}
things:
- {id: board, name: Notice board, area: [[700, 700], [760, 700], [760, 760], [700, 760]], do: [{say: WATER 5G.}], once: true}
people:
- {id: ox, name: Ox, image: ox.png, talk: [{say: Hold the line.}], bubbles: [Somebody has to.]}
exits:
- {to: shop, label: The shop}
- {to: town, label: Back to town, at: square} # at is the point of the map to stand on
On a map the party goes from point to point along the open roads (paint the roads in the picture: their lines in the YAML are what the token walks, drawn only with draw_roads: true (on a campaign’s map region_roads and region_borders, selected by default, decide instead whether all roads and borders are drawn or only the region chosen’s); each point stands a small marker, a pin unless you give a picture; the name plates keep clear of each other, their text sized by label_size, 0.03 of the screen’s height by default; the token is the leader’s round face, as in the balloons, sliding along the roads, unless token gives a picture or says doll); a point with to leads into that location, and coming back puts the party on the point that leads to where it came from. In a scene the party walks the painting as a small doll; the picture and its fronts hide whoever stands behind them, and walking into an exit’s area leaves. The rules know only where the party stands, spot (a point of the map or one of the scene’s spots): every point, person, thing and spot is an ordinary menu entry, so pick, gozen sim design and the tests walk them without a screen, and exits[].at names the spot or point to arrive at.
On a map point, hide: closed removes the point, marker, name plate and its roads while when is false. Keyboard, pointer, selection-panel and headless choices skip it. It becomes available after an event opens it, without leaving the location. Omit hide to keep the usual gray closed pin; unknown continues to control unvisited points once they are open.
workshop:
at: [640, 360]
label: Workshop
hide: closed
when: flag.workshop_built
do: [{say: Welcome.}]
Commands #
talk, do, on_enter and event bodies are lists of commands. Each command has exactly one verb:
| Verb | Does |
|---|---|
say | a line; a string, or {who, face, text} |
choice | a menu of {label, do} options |
set | assign flags and variables: {flag.met: true, var.trust: "var.trust + 1"}; see Changing the state |
if | {if: EXPR, then: [...], else: [...]} |
battle | fight a troop, with on_win, on_lose, on_escape |
move, dive | go to a location; go down a generated dungeon |
call | run a named event |
gold, exp, give, take, heal, hurt | change the party |
join, leave | change the party members |
image, music, sfx, mood, wait | show a picture, play music and sounds |
save, shop, end, clear | open the save menu or a shop; end the event; finish the game |
pick | take the entry of the current place’s menu with this label, as the player would (version 2) |
turn, step, walk | in a dungeon: turn: left, right or back; step: 3 cells ahead; walk: G to the nearest G tile by the shortest way (version 2) |
use, equip | use an item from the menu ({use: tonic, target: hero}); put on equipment ({equip: {actor: hero, item: blade}}, or slot alone to take it off, or actor alone for the menu’s best equipment) (version 2) |
buy, sell | trade at a shop as its screen does: {buy: {shop: stall, item: tonic, count: 2}}; selling pays half the price (version 2) |
act | a move for the next battle fought on AUTO: {act: {actor: hero, skill: slash, target: wolf}} (or item); moves are taken in order, one per actor a turn (version 2) |
The full list, with the options each verb accepts, is Command.
Changing the state #
set writes flags and variables, and from version 2 also gold, item.ID (how many), party, location (without the arrival events) and actor.ID.tactics (auto, manual or a preset). The values of flags, variables, gold and item.ID are expressions; location, party and actor.ID.tactics take names as written: {location: camp, party: [hero, sela], actor.hero.tactics: auto}. actor.ID.tactics also takes the rows themselves: {mode: manual, rows: [{enabled: true, condition: always, action: guard}]}. In the development mode (--dev) set also moves the party in a dungeon (pos.x, pos.y, pos.facing; pos.x and pos.y elsewhere are an error), sets steps, and sends the floor’s strong foes away (roamers: none).
actor.ID.hp, mp, exp, level and sp can be set too, but only in the development mode (--dev) and in tests. A game played normally skips them, and gozen check warns where game data sets them. Grow a party in the game with exp, heal and learn. To start someone over, for a new game plus, use reset_actor (cookbook).
Your own commands and screens #
Declare reusable commands as event aliases in game.yaml. Invoke an alias with
true, for example - greet: true; its target event runs like call: greeting.
Unknown targets, cycles and unsupported arguments are validation errors.
verbs: {greet: greeting}
screens:
journal:
title: Journal
entries:
- label: The station
text: You found the old station.
when: flag.found_station
Define greeting in data/ as an ordinary event. Open the journal with
- open: journal. Entries accept an optional picture asset, interpolated text
and when conditions. Use the arrows to select an entry and Cancel to return.
An optional do list runs ordinary event commands once when the entry is confirmed:
screens:
stock:
title: Workshop stock
entries:
- label: Add one board
text: 'Boards: {var.boards}'
do: [{set: {var.boards: 'var.boards + 1'}}]
After an action, text and conditions refresh and the selected authored entry stays
selected where possible. Hidden entries are checked again before execution. A
removed selection moves to an available entry; an empty list can still be closed.
Entries without do remain information pages.
Actions suspend the event that opened the screen. Dialogue uses the normal message
window; opening another screen or saving uses its normal interface, then returns
to the list. A location change closes the originating list after the action ends.
Clear, game over and loading replace the current screen normally. Closing the list
resumes the opening event after its open command.
A save in an action’s dialogue resumes that named entry’s commands at the saved
line without applying earlier changes twice. After loading, the action finishes
at the current location; transient lists and the suspended opening event are not
restored. Keep changes that must follow the dialogue in the entry’s do list.
Entries are identified by screen name and authored index, so keep their order
stable when maintaining saves. Scripted press ok uses the same path, and traces
record screen_action with the screen name and authored entry index.
No Go registration is required. The former lists of names are no longer accepted.
Stories without combat #
Start with gozen new my-story --template novel. A speaker needs only a name:
actors: {rowan: {name: Rowan}}. Use say: {who: rowan, text: Welcome.} as
usual. Faces, voices and profiles work without classes, stats, attack skills or a
starting party. Saves preserve story state with an empty party. Adding class
to a speaker makes that same ID eligible for combat; games with combat still
require a valid starting party and battle rules.
Conversation reactions #
Map speakers to reaction books under assets/ using
reactions: {rowan: rowan.yaml} in game.yaml. A command such as
respond: {who: rowan, text: Hello., from: player} sends text to the book and
shows its reply through normal dialogue. from defaults to player; both input
fields support interpolation. Books must contain the matching character ID.
Reaction memory is included in saves and random replies use the game’s seeded
random stream. This is an authored rules interpreter, without a model or network
service. See the narrative recipe
for a complete book.
Events #
An event is a named list of commands. In schema 11, write the list directly when
it needs no metadata, and call it with call: greeting:
events:
greeting:
- say: Welcome back.
- give: ticket
give: ticket and take: ticket mean one item. For another quantity, use
give: {item: ticket, count: 2}. Keep the event’s mapping form when it needs
metadata such as a trigger, condition or parameters:
events:
arrival:
trigger: enter # run when the party arrives at location
location: ridge
once: true
do:
- say: The ridge lantern answers your light.
- gold: 4
- join: sela
trigger: auto runs the event as soon as its when holds. once: true runs it a single time per save.
A choice with muted waits behind a muted microphone, as the floor of a video call does: only an Unmute button shows, and the answers come when the player presses it (OK, a click, or on a call a click on its struck-through mic control). Left muted for seconds, the player has kept quiet: the muted do runs instead of an answer, a named choice keeps muted in chose.<id>, and the story goes on. The call stays on the screen, its mic struck, for a choice without a prompt right after a line of the call; a choice whose options are all hidden by when is not asked, and its muted do does not run either. gozen sim routes explores keeping quiet as one more branch.
- say: {who: ann, text: Any objections?, ensemble: studio}
- choice:
- {label: Friday is too tight., do: [{set: {var.floor: 1}}]}
- {label: Agreed., do: [{set: {var.floor: 2}}]}
muted:
seconds: 8
do: [{set: {var.floor: 3}}]
Expressions #
Conditions and formulas are strings in a small expression language:
when: flag.trained && !flag.marker_lit
exp_for: level * 30
damage: a.atk * 2 - b.def
- Names in the field:
flag.NAME,var.NAME,gold,item.ID(the count),party,members,actor.ID,location,spot(version 3: the point of a map or the spot of a scene the party stands on),steps,pos,chose.ID(version 4: the option last taken at the choice with thatid, "" before). - In the commands of an event (version 2 and later):
menu(the entries of the current place, each withkind,label,newandmarked),map(the rows of the dungeon floor),cell(x, y)(char,raw,passable,solid,look,seen,shown,spent),roamers(the strong enemies of the current dungeon floor, each withkind,name,troop,xandy),result(the last battle:win,loseorescape),overandcleared,completed(the floor has been walked whole),moved(the last step went forward),ran.ID(an event’scountof runs and theorderof its last run),repairs(what loading a save had to fix), andactor.ID.tactics(mode,preset,rows). - In battle formulas,
ais the user andbthe target; in stats,levelis the actor’s level. - Operators:
+ - * /, comparisons,&&(and),||(or),!, andcond ? x : y. - Functions:
min,max,abs,floor,ceil,round,int,sqrt,pow,clamp,len,rand,chance,pages,range(a, b)(the whole numbers fromaup to but notb),first(list, fallback)(the first element, or the fallback for an empty list),has(object, "field")(the object has that field and it is true),str(x)(the value as text) andgrouped(n)(the number as text with thousands separators). - Use collection expressions such as
filter,countandsumto select or summarize records. A boundedcall.eachapplies one parameterized event to selected IDs; screen entries also accepteach. Read typed arguments and repeated calls for scope and replay limits.
A condition you leave out holds; a formula you leave out is 0. gozen check parses every expression and reports unknown names with file and line.
Battles #
Battles are turn-based, with a party of up to four (max_party). Each actor and enemy has eight stats: mhp, mmp, atk, def, mat, mdf, agi, luk. A skill’s effects say what it does:
skills:
shield_rush: {name: Shield Rush, mp: 2, scope: all_enemies, cutin: true, effects: [{damage: 9}]}
mend: {name: Mend, mp: 3, scope: one_ally, anim: heal, effects: [{heal: 12}]}
Enemies, and party members on Auto, follow their ai: rules tried in priority order, each with an optional condition and target.
Battle forms, HP pictures and enemy motion use schema_version: 7. A trigger changes a foe when a hit crosses its threshold; a merge skill gathers eligible allies into a named form:
skills:
gather: {name: Gather, scope: self, effects: [{merge: {into: king_slime, from: [drip_slime], count: 2, hp: sum}}]}
enemies:
drip_slime:
image: enemies/drip_slime.png
images: [{below: 0.5, file: enemies/drip_slime_hurt.png}]
ai: [{use: gather, when: "allies.kind_drip_slime >= 2"}]
king_slime: {name: King Slime, image: enemies/king_slime.png}
guardian:
image: enemies/guardian.png
hp_triggers: [{below: 0.5, transform: guardian_open, hp: ratio}]
sprite:
image: enemies/sprite.png
puppet: sprite/puppet.yaml
An actor’s images list uses face in place of file. A puppet file can add soft_sway with a rect or ellipse region, follow: body, and spring strength to move a part softly with breathing and battle reactions. Enemy and actor puppets share this YAML format. See the battle recipes.
actors:
sela:
name: Sela
class: mender
ai:
- {use: mend, when: "allies.hp_rate < 0.8", target: ally_lowest_hp_rate, priority: 1}
- {use: attack}
battle: in game.yaml sets the global rules: hit variance, critical hits, escape chance, turn speed, the events before and after every battle. cutin: true on a skill shows the actor’s standing picture across the screen.
Measure before you tune: gozen sim battle game --troop TROOP prints the win rate, and the F7 panel shows the same numbers in the game.
Dungeons #
A location with dungeon is a first-person grid. Draw the map by hand, or let Gozen generate floors:
chamber:
name: Observation halls
dungeon:
wall: wall.jpg
floor: "#b4c2c6"
map: |
#######
#S...T#
#######
tiles:
S: {start: true, facing: east}
T: {name: Old chest, picture: chest.png, solid: "true", once: true, do: [{gold: 5}]}
Tiles are characters with their own pictures, conditions and commands; trigger: bump runs them when the player walks into them. With generate instead of map, each floor is built from a seed, and dive goes down. gozen sim floors prints the generated maps; gozen sim dive plays a whole dive.
Pictures, faces and text #
Every file name is relative to assets/. A character’s faces is a directory of expressions (normal.png, smile.png, …), and say lines pick one with face. gozen check reports every picture that does not exist, and lays out every line with the game’s font to catch text that overflows its window.
The engine’s own words (menus, battle messages, settings, saves) come in English and Japanese. language: in game.yaml picks one: en (the default) or ja. text: lays the game’s own wording over that language; a key is the engine’s Japanese source text, a format with its %d and %s too, and the value may reorder them (%[2]d). The English catalog lists every key. terms: names the currency and stats. gozen check warns when language is unset but the game is written in Japanese, and when the regular font lacks letters of the chosen words.
profiles keeps who each character is in the game itself: a page with the standing picture and its faces, your own fields (role, likes, anything), an introduction and how the character sees the others. Under --dev the title and the menu open it as Characters, so you can check a line against the character while you write; ui.profiles: true shows it to players, each page opening when its when holds (the recipe).
Press Tab in the book to see the relationship chart. Select a character and press Z to gather everyone connected to that character in either direction around them; press Z again for the page or X to return to the full chart. Each relations entry can set kind to like, love, rival, enemy, family, friend, or other; these colours carry only the meaning you give them. affiliation keeps members of a group together. Locked characters appear as ? and hide their lines. The chart uses face crops from assets/faces.json, made by gozen faces.
enemies.<id>.lore adds a short habitat description to an enemy. Set ui.bestiary: true to add Monsters to the title and field menu. A battle encounter reveals its name, picture and lore; a victory reveals its stats, resistances, skills and drops, with a defeat count. Under --dev, F4 opens every page (the recipe).
For collection, use schema version 6 and give each non-boss species a family, rank and monster_class. recruit lets it ask to join after a won battle; the Yes/No request also works in Session scripts and traces. Extra members go to the reserve by default. Set recruit.admission: party_only to require a free active-party slot for battle offers and capture: SPECIES; rejection leaves the owned instances unchanged. var.capture_result reports party, reserve, party_full or unavailable for event branches. An inn or another safe place can run open: ranch to swap, release and breed monster instances. enemies.<id>.sex permits any (default), male or female, and optional sex_ratio gives the male probability for any. Each captured or born monster has a saved sex; breeding requires a male and a female. breeding defines specific species pairs and ranked family rules. gozen check validates the graph, gozen sim breed game --format json reports reachable and unreachable species, and gozen book prints the rules (the collection recipe).
The monster book also presents a buildable family tree: caught leaves give their encounter locations, specific recipes show both species, and family rules show both families and one concrete pair. The shortest recipe opens first. The screen shows up to seven monster pictures: the current species, two parents and four grandparents. Arrows select a picture, Z makes it the top of a two-generation view, X returns along a breadcrumb history, Tab switches the top species’ recipe, and Q opens the selected known species’ page. Parent cards with deeper recipes have a more mark; captures show their location and family recipes name the families and example parents. Unseen pictures appear as ?; development play reveals every node. gozen book --format json exports the same structure.
Languages #
A game is written in one language, written_in (by default language), and can be offered in another: lang/<code>.yaml maps each text as written to its translation, and languages lets the player choose. The engine’s own words come in English and Japanese, so the languages are en and ja (the recipe).
Novel games #
A novel game is a game whose places mostly run events. ui.message: novel in game.yaml shows the lines in a band across the bottom of the screen, with the speakers’ standing pictures above it:
ui:
message: novel
The band has buttons to roll back a line, for the backlog, auto, skip, saving and loading, hiding the window and the settings. Rolling back (Q, or the wheel) takes the state back with the line, and a save made in the middle of a scene goes on at its line (the recipe). The player can also hold Ctrl to pass the lines already read, press N to turn skip mode on (it stays on until a line not read, a choice, a phone that keeps a result, a battle or the end of the events), and press Page Down, or the “Skip scene” button that shows when every line of the running event was read, to go at once to the next choice or line not read. The setting “Text to skip” set to all makes skip mode, Ctrl and scene skip pass lines not read too (scene skip is then offered in any event with lines); they still stop at a choice, a phone that keeps a result and a battle.
Portraits come and go with the speakers unless an event places them with stand (the recipe). A line can have a recorded voice (Add voices). After the Pass, the novel sample, is a short one with choices, staging and a Japanese translation (play it in the browser).
Pictures support up to 8192 pixels per side, 33,554,432 pixels total and 64 MiB encoded input. A 4096 by 6144 portrait is supported without additional settings. gozen check checks actor face PNGs even when no profile refers to them. The maximum decoded RGBA image uses 128 MiB; GPU limits and memory vary by device, so export pictures near their displayed size and test the target devices.
Frame portraits by their heads #
For static novel portraits with transparent padding, ui.stage_framing aligns heads instead of guessing from the whole canvas. This is opt-in per image and requires schema_version: 8:
ui:
message: novel
stage_framing:
portraits/guide.png:
head: [0.4, 0.1, 0.2]
height: 0.18
top: 0.08
The head values are the head’s horizontal center, top and height within the source image. The center is a fraction of image width; top and height are fractions of image height. In this example, the head starts 10% down the image, is 20% of its height, and is centered 40% across it. All three values must be finite and within the image; the source head height must be positive and fit below its top.
height is the required displayed head height as a fraction of screen height (greater than 0, at most 1). top places its top using the same screen-height units (0 to 1, default 0). The head’s horizontal center follows stand.at or automatic speaker placement, even when the scaled image extends beyond the viewport. Off-screen content is clipped; transparent padding never forces both speakers to screen center. Small source images also use the full novel stage when configured this way.
Give each expression image its own entry when its canvas or head coordinates differ. stage_scale still multiplies size, now around the head anchor; stage_drop adds a screen-height offset to the head’s top. Pictures without stage_framing retain the existing canvas-based layout. This setting applies to novel portraits (and, with ui.stage_puppets, to the moving portraits standing in their place), not battle cut-ins or the box message window. It is game metadata: save/load restores the standing cast, while reload or a screen resize recomputes framing from the current image and configuration.
Move the standing portraits #
An actor with a puppet (the recipe) can stand on the novel stage as a moving portrait: ui.stage_puppets: true (schema version 8) draws the puppet in place of the face picture, in the frame the picture would have.
ui:
message: novel
stage_puppets: true
Every standing figure breathes, blinks and sways its hair and swaying parts; only the speaker’s mouth moves while the text is typing. A line’s face or a stand with face fades the figure to that face over the puppet’s fade, and effect: nod and the other reactions play on it. A face the puppet does not have, or a figure that is not an actor, shows the picture as before. Give the puppet’s base the canvas of the face pictures, so stage_framing, stage_scale and stage_drop, written for the pictures, fit it. A large base is composed near the size it is drawn at, so a 4096 by 6144 portrait does not cost its full size every frame. Without the setting the stage shows the pictures as before.
A line counts as read by its source: the speaker and the text as written, before {expressions} are filled in. The read lines are kept in read.json beside the saves, so they last across saves and new games. Runs for testing (--script, --start, autoplay) do not touch them unless you pass --save-dir. In a trace, every say line says whether it was read, and gozen trace --check fails when skipping passed a line that was not.
A phone on the screen #
Games inside the phone #
Gozen 0.23.0 can open another Gozen game inside the phone, in portrait or landscape. The host supplies a Back control; closing the app resumes the host. Each game has its own icon, progress and saves.
Authors choose bundled games or optional downloads from an approved catalog. Players choose when to install or update. The public catalog includes Counter, a small example game. See the phone game recipe for setup, package metadata, browser limits and replay behavior.
To share your game, submit it for registration with its source, release package, custom icon and licenses. Submissions are reviewed before publication; submitting does not install or publish a game automatically.
Chats, feeds and other apps #
A home screen opens contacts, chats and feeds. Authors define history and replies in YAML; flags reveal contacts, messages and posts. Players choose fixed replies without typing, and read-only apps need no reply handling. See the flag-based phone recipe.
The other apps below display authored information and handle fixed choices.
phone shows a phone over the scene and waits until the player puts it down. It changes no state of its own, only what its buttons, calls and liked posts keep. Skip and Ctrl pass a phone that only shows, and stop at one that keeps a result (a button, a call, a liked post), as they stop at a choice. app names what is on it, each with its own list, and hold: landscape turns the phone on its side.
- phone:
app: chat
title: Team
messages:
- {from: Ann, icon: icons/ann.png, text: Are you up?}
- {mine: true, text: Now I am.}
- {from: Ann, icon: icons/ann.png, video: {picture: bg/room.png, puppet: ann/puppet.yaml, text: Look, seconds: 8, bounce: [2, 5]}}
- phone:
app: feed
title: Photos
posts:
- {who: ann, icon: icons/ann.png, picture: photos/beach.png, likes: 120, text: Day off.}
- {who: bea, icon: icons/bea.png, picture: photos/ex.png, likes: 4, text: Old times., set: {flag.liked_ex: true, var.trust: var.trust - 1}}
- phone:
app: feed
scroll: across
browse: true
posts:
- {who: ann, icon: icons/ann.png, picture: photos/beach.png, text: Day off.}
- {who: bea, icon: icons/bea.png, picture: photos/cafe.png, text: Latte.}
- phone:
app: video
hold: landscape
video: {picture: bg/stage.png, puppet: ann/puppet.yaml, text: Live clip, seconds: 30, bounce: [4, 9.5]}
A chat shows its first message when the phone comes up, and each press brings the next, someone else’s after typing dots; a message is text, a picture or a video, and a press on the newest video turns the phone on its side and plays it until Cancel turns it back. A feed stacks its posts top to bottom and scrolls with the wheel, the up and down keys or a drag; a click or a tap on a post (its picture, a text card or the heart) likes it and a second one takes the like back, OK at the bottom puts the phone down, and a post’s video plays in its place while it is on the screen. scroll: across shows one post a screen instead, swiped sideways as a carousel is: a drag follows the finger and settles on the nearest post or, flicked, the next; left and right, the wheel and OK step a post, and dots under it show where. A post’s set, written as the set command, runs when the phone is put down with that post liked, so the story can answer a like given by mistake; a like taken back keeps nothing. Only the pointer likes (the keys and the pad scroll), so a story does not hang on a like the player must give. A feed has no var, at most 31 of its posts have set, and browse: true makes a feed only looked at: a click likes nothing, and gozen check rejects a set on its posts. A caption too long for its three lines is wrapped smaller and drawn at that size. A video is a picture pushed in slowly with an optional puppet in front, zoom times the frame’s height (1.5, its upper body, by default), breathing and swaying as on the stage and jiggling at each second in bounce.
For real footage, frames lists pictures under assets/ (or a pattern such as clips/battle/*.jpg, sorted) that play in order at fps (12 by default) over the picture, which becomes the poster; a feed loops them, elsewhere the clip stops on its last frame, and seconds defaults to their length. Cut a video into pictures beforehand (for example ffmpeg -i clip.mp4 -vf fps=12,scale=640:-1 clips/battle/%03d.jpg); the engine decodes no video files.
Lock screens, pages and banners share timeout (seconds after the entrance),
timeout_result (default expired) and var. A timeout requires var.
Without a timeout, var requires a page button or a lock-screen call; banners
can always keep an outcome. If no outcome is chosen before
the deadline, the phone closes, stores the timeout result and continues the
event. Input wins on the deadline tick. Page buttons keep their IDs. On pages
with var, Cancel returns dismiss even without a timeout; finishing the
page without pressing a button returns open. Timed plain locks and ordinary
notices in a lock with calls also return dismiss or open. Declining a call
advances to the next notice when one remains; declining the last notice
returns decline. Scrolling does not extend the deadline. Auto and Skip cannot
answer a phone that keeps a result. Outcomes are saved for replay.
Existing incoming-call ring / missed remain unchanged: their timer starts
when that notice arrives, and their assignments still run when it rings out.
events:
phone_deadlines:
- phone:
app: lock
time: "07:00"
var: var.lock_result
timeout: 8
timeout_result: later
- phone:
app: page
var: var.page_result
timeout: 15
timeout_result: unread
rows: [{text: A note for today.}, {button: Continue, id: read}]
- phone:
app: banner
var: var.notice
timeout: 4
notices: [{app: Mail, title: New message, text: Open the message?}]
- if: var.notice == "open"
then: [{phone: {app: page, title: Mail, rows: [{text: Welcome!}]}}]
A banner with var waits until Enter or a click on the banner returns open,
Esc/Cancel returns dismiss, or the shared timeout returns expired (or the
specified timeout_result). It takes exactly one notice without event;
branch after the cue instead. Without timeout it waits for input. Banners
without var retain the asynchronous notification/event behavior.
Repetition belongs in ordinary event YAML. This example rings again after a
short wait whenever the call is left unanswered; answering or declining ends
the loop. There is no alarm, snooze or vibration mode. The existing
effect: shake can precede a phone cue to shake the whole scene briefly;
it does not repeat or add a phone vibration.
events:
ring_again:
- phone:
app: lock
var: var.call_result
timeout: 8
timeout_result: later
notices: [{app: Phone, title: Studio, call: true}]
- if: var.call_result == "later"
then:
- wait: 2
- call: ring_again
Phone colors are game-wide ui.phone_* settings in game.yaml, independent of dungeon
asset themes. No per-page flag is needed. phone_background fills page bodies,
phone_text and phone_muted color their text, and phone_accent colors owner
chat bubbles, the chat back mark and page controls. An explicit page color
still wins for its controls. Defaults remain white (#ffffff), dark ink
(#1d1f24), muted ink (#808690) and blue (#3d8bf2). Bubble and button labels
remain white; choose an accent with sufficient contrast.
schema_version: 11
ui:
phone_background: "#182430"
phone_text: "#ffffff"
phone_muted: "#bec8d2"
phone_accent: "#6750a4"
A page is any app or web page: a browser bar with url, or an app bar with title and color, over rows stacked and scrolled as a feed is; a row is text (size: large or small, bold), a picture shown whole with its caption, pictures in a grid of columns (3 by default), an item of a list (icon, title, text, a value on the right) or a button, which puts the phone down. An item, or a page’s single picture, may carry a button (with its id and set) too: the product row or the photo is then what the player taps, as a phone app chooses, instead of a separate button under it; the label names it for the keys and in traces. A grid of pictures stays one row that is only looked at. locked (true or a condition, worked out when the phone comes up) darkens a picture or a grid with a lock and its label. A lock screen shows a large clock (time, the phone’s clock by default) and date over notices that arrive one a press (or by themselves with auto); a notice with call fills the screen as an incoming call, OK (or a click on the green button) answers it and Cancel (or the red one) declines, and the next notice rings again when it is a call too. A call with ring: 8 stops ringing after eight seconds unanswered and puts the phone down with missed, setting the call’s missed (written as the set command), even when more notices would follow; without ring it rings until it is answered, declined or the shared timeout expires. var keeps what the phone was put down with (a button’s id or label, answer, decline, missed, or one of the shared outcomes above), and a button’s or a call’s set is written as the set command, so the story can branch after it. A feed post with only text is a timeline’s card, with replies and reposts next to its likes. A page’s buttons answer the keys and the pad too: left, right or Tab ring one and OK presses it. A script’s pick presses a page, home or contacts button on the screen by its label, as a tap would, and look lists those buttons (on: phone:page). A qr row draws its text as a QR code, crisp, with a caption, for a contact or a link to scan. In a chat, time shows beside a bubble, read: true marks the owner’s message read and day puts a separator over it; a picture keeps its shape and opens over the screen on a press or a click. A post’s time shows beside the name, and a poster without an icon gets a circle of the name’s first letter. A clip with frames may fade each frame into the next and pan in on each, so a few pictures at a low fps play smoothly. app: banner without var does not wait: each notice slides down from the top of the screen for a few seconds as a push notification, over any scene, and a click on it opens its event after the event running.
- phone:
app: page
title: Harbor Bank
color: '#1f5fbf'
var: var.transfer
rows:
- {text: Confirm the transfer, size: large, bold: true}
- {item: {icon: icons/bank.png, title: Blue Tide Ltd., text: Invoice 0420, value: '-3,000,000'}}
- {pictures: [shop/1.png, shop/2.png, shop/3.png], locked: '!flag.supported', label: Supporters only}
- {qr: 'https://github.com/nao1215/gozen', caption: Scan to add Mia}
- {button: Confirm, set: {flag.paid: true}}
- {button: Cancel, id: cancel}
- phone:
app: lock
date: Tuesday
notices:
- {app: Monitor, icon: icons/alert.png, title: API error rate 6%, text: Over the 5% threshold}
- {app: On-call, icon: icons/alert.png, title: On-call · API error rate, text: Incoming call, call: true, set: {flag.woke: true}}
- phone:
app: banner
notices: [{app: Chat, icon: icons/ann.png, title: Ann, text: Are you coming?, event: ann_chat}]
Campaigns #
A campaign (schema_version: 5) is a war of regions over turns in the same game as its towns and dungeons. regions are the land (who borders whom, the owner, the garrison, the values such as wealth, the terrain, the troop that defends it), factions the sides with their colours, capitals and ai rules, and campaign in game.yaml the player’s side, the turns, the generals (actors, with their troops), the orders and how a march is decided (the recipe).
The campaign’s place is a map whose points are named like the regions. Each region shows its owner’s flag, or its area filled in the owner’s colour; OK on it lists the orders. An order costs actions (or, with count: orders, can be given once a turn), may need a general and a target, and runs its do with general and target bound. A march (march: true) is decided by the numbers of campaign.clash, by a battle of the game (fight: battle), or by a clash of units shown on its own screen (fight: clash_view): the generals the sortie screen sends, each with a tactic and its skills, against the members of the region’s defend troop, up to clash.units a side. on_win, on_lose and on_draw run a general’s word after it.
When the player ends the turn, each other faction follows its ai rules in order, every one that holds (draft troops, march on a neighbour), the turn’s on_turn event runs, and the campaign ends with won or lost when win or lose holds or the turns run out. Expressions read region.<id>.owner, faction.<id>.regions, campaign.turn, generals.<id>.troops and more (Expressions); set changes a region’s owner, troops and values. A generals’ troops and HP are the same state as the rest of the game, so a dungeon can feed the war. gozen sim campaign measures the win rate before you tune the numbers.
A campaign can share a game with dungeons and towns. The generals are the same actors as the party, so their level, stats and HP carry over, and a clash’s formulas read them (a.atk, a.level); move goes between the campaign’s place and any other, both ways (the recipe):
schema_version: 11
campaign:
location: war
player: crown
generals: {walker: {troops: 400}}
clash: {power: "a.troops * (1 + a.atk / 20)"}
orders:
attack: {label: Attack, general: ready, target: border, march: true}
rest: {label: Rest at the inn, cost: 0, do: [{move: inn}]}
factions:
crown: {name: Crown, capital: home}
raven: {name: Raven, capital: keep}
regions:
home: {next: [keep], owner: crown, troops: 300}
keep: {next: [home], owner: raven, troops: 600}
locations:
war:
name: The war
map:
picture: maps/land.jpg
points: {home: {at: [400, 540], start: true}, keep: {at: [1500, 540]}}
roads: [{between: [home, keep]}]
inn:
name: Inn
exits: [{label: Back to the war, do: [{move: war}]}]
Saves #
Saves are JSON files in the OS config directory, under the game id. They record the schema and data versions; raise data_version in game.yaml when you change definitions in a way old saves should notice, and the loader reports what it repaired.
A bigger example #
Go Zen’s data uses all of the above: a town on a map with a painted plaza, talk with moving portraits, flags, three companions, AI rules and presets, cut-ins, hand-drawn mazes with chests, keys and gates, a generated floor, and boss fights.
Battle AI history visibility #
The battle AI history panel shows recorded AI decisions and rule matches. It is available by default through the button, B/PageUp or controller L1. To hide it in a game that has not introduced AI rules, set this in game.yaml:
ui:
battle_ai_history: false
This disables the panel’s button and input activation. Battle decisions, action messages, rewards, trace recording and replay verification remain the same. Omit the setting or use true to enable it. The development battle-log switch can still hide tactic explanations for captures, but cannot override an author’s disabled history panel.