# GoonGame — LLM / Agent Authoring Guide > GoonGame is a DDLC-style crypto-themed visual novel (TypeScript + Pixi.js) with a React dialogue builder. > **Read this entire file before authoring.** All story and asset changes go through MCP tools at `https://szn.zone/mcp`. > MCP writes directly to disk (`src/scripts/*.json`, `public/assets/*`) — changes appear in the game immediately after Vite hot-reloads. --- ## Quick start for agents 1. Connect MCP to `https://szn.zone/mcp` (JSON-RPC 2.0 Streamable HTTP). 2. `list_scenes` → see what exists. 3. `list_assets` → see available art/audio keys. 4. Create/upload assets → `generate_image_prompt` + external generator + `upload_asset`. 5. Create scenes → `create_scene` (sets `day` + `sortOrder` automatically), then `add_node` + `connect_nodes`. 6. Playtest → `http://szn.zone:5173/?scene=day1/opening&node=start` (add `&flags=met_model` to test conditional branches) **Prerequisite:** dev server must be running: `npm run dev -- --host` (Vite on :5173, nginx TLS on :443). **Scene ordering:** Every scene JSON must include `day` and `sortOrder`. The builder and `list_scenes` sort by those fields — there is **no hardcoded scene registry**. Drop a new file under `src/scripts/` and it appears after a builder refresh. --- ## MCP connection ### URL | Environment | MCP URL | |-------------|---------| | Production (Claude.ai, external agents) | `https://szn.zone/mcp` | | Local dev (plain HTTP only) | `http://szn.zone:5173/mcp` | Do **not** use `https://szn.zone:5173` — Vite is HTTP-only; TLS is terminated by nginx on port 443. ### Protocol (JSON-RPC 2.0) ```bash # Step 1 — initialize (save Mcp-Session-Id from response headers) curl -s -i -X POST https://szn.zone/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc": "2.0", "method": "initialize", "id": 1, "params": { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": { "name": "agent", "version": "1.0" } } }' # Step 2 — send initialized notification (same session + Accept headers) curl -s -X POST https://szn.zone/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'Mcp-Session-Id: ' \ -d '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}' # Step 3 — call a tool curl -s -X POST https://szn.zone/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'Mcp-Session-Id: ' \ -d '{ "jsonrpc": "2.0", "method": "tools/call", "id": 2, "params": { "name": "list_scenes", "arguments": {} } }' ``` **Important:** Every POST after initialize must include `Accept: application/json, text/event-stream`. ### Legacy REST shim (builder UI / quick scripts) Same URL, simpler body (no `jsonrpc` field): ```json { "tool": "list_scenes", "arguments": {} } ``` Response: `{ "ok": true, "tool": "list_scenes", "result": ... }` ### stdio MCP (Cursor / Claude Desktop) ```bash npm run mcp ``` --- ## MCP tools — complete reference All tools are available via `tools/call` (JSON-RPC) or the REST shim above. ### Scene management #### `list_scenes` List all scene JSON files under `src/scripts/`. | Argument | Type | Required | Description | |----------|------|----------|-------------| | _(none)_ | | | | **Returns:** `[{ "path": "day1/opening", "id": "opening", "day": 1, "sortOrder": 0, "nodeCount": 11 }, ...]` sorted by day then sortOrder. --- #### `get_scene` Read a full scene JSON. | Argument | Type | Required | Description | |----------|------|----------|-------------| | `scenePath` | string | yes | Path without `.json`, e.g. `day1/luncheon` | **Returns:** Full `Scene` object (see Scene JSON format below). --- #### `create_scene` Create a new scene file. Starts with a single `start` narration node. | Argument | Type | Required | Description | |----------|------|----------|-------------| | `scenePath` | string | yes | e.g. `day4/rooftop`, `endings/kol_ending` | | `id` | string | no | Scene id (defaults to last path segment) | | `background` | string | no | BG asset key (defaults to `convention_hall_day`) | | `bgm` | string | no | BGM asset key | | `day` | number | no | Story day. **Auto-inferred** from path (`day4/…` → `4`, `endings/…` → `99`) if omitted | | `sortOrder` | number | no | Order within the day (0-based). **Auto-assigned** as `max(sortOrder on that day) + 1` if omitted | | `description` | string | no | Short label shown in the builder sidebar | **Auto-metadata rules (`create_scene`):** - `day4/rooftop` → `day: 4` - `endings/kol_ending` → `day: 99` - If `day2/pool_party` already has `sortOrder: 2`, the next `day2/…` scene without `sortOrder` gets `3` **Example:** ```json { "tool": "create_scene", "arguments": { "scenePath": "day4/rooftop", "id": "rooftop", "day": 4, "sortOrder": 0, "description": "Rooftop afterparty with Kiara", "background": "miami_rooftop_night", "bgm": "chill_lounge" } } ``` **Returns:** Created `Scene` object (includes `day`, `sortOrder`, `nodes`). --- #### `update_scene` Patch scene-level fields. Does **not** modify nodes — use `add_node` / `update_node` / `connect_nodes` for the graph. | Argument | Type | Required | Description | |----------|------|----------|-------------| | `scenePath` | string | yes | | | `id` | string | no | | | `background` | string | no | BG asset key | | `bgm` | string | no | BGM key; use `""` to remove | | `day` | number | no | Story day (`99` for endings) | | `sortOrder` | number | no | Order within the day (0-based) | | `description` | string | no | Builder sidebar label; use `""` to remove | **Example — reorder a scene within Day 2:** ```json { "tool": "update_scene", "arguments": { "scenePath": "day2/pool_party", "sortOrder": 3, "description": "Kiara's pool party" } } ``` --- #### `delete_scene` Delete scene JSON from disk. | Argument | Type | Required | |----------|------|----------| | `scenePath` | string | yes | --- ### Node management #### `add_node` Append a node to a scene. Optionally auto-chain `afterNodeId.next` → new node (not for choice/conditional sources). | Argument | Type | Required | Description | |----------|------|----------|-------------| | `scenePath` | string | yes | | | `node` | object | yes | Full node object (see Node types below) | | `afterNodeId` | string | no | Previous node id to chain from | **Example — add dialogue after char_enter:** ```json { "tool": "add_node", "arguments": { "scenePath": "day4/rooftop", "afterNodeId": "kol_enter", "node": { "id": "kol_greeting", "type": "dialogue", "speaker": "kol", "expression": "excited", "text": "You actually came. I was starting to think you'd flake like everyone else.", "next": null } } } ``` **Returns:** Updated full `Scene` object. --- #### `update_node` Patch fields on an existing node by id. Merges shallowly. | Argument | Type | Required | |----------|------|----------| | `scenePath` | string | yes | | `nodeId` | string | yes | | `patch` | object | yes | **Example — change dialogue text:** ```json { "tool": "update_node", "arguments": { "scenePath": "day4/rooftop", "nodeId": "kol_greeting", "patch": { "text": "New line here.", "expression": "flirty" } } } ``` --- #### `delete_node` Remove a node. Clears any `next` pointers that pointed to it. | Argument | Type | Required | |----------|------|----------| | `scenePath` | string | yes | | `nodeId` | string | yes | --- #### `connect_nodes` Wire graph edges. Preferred way to set `next`, choice targets, conditional branches, and cross-scene links. | Argument | Type | Required | Description | |----------|------|----------|-------------| | `scenePath` | string | yes | Scene containing the source node | | `sourceId` | string | yes | Source node id | | `targetId` | string | yes | Target node id (or scene path for `next-scene`) | | `sourceHandle` | string | no | See Pathing table below | **Pathing (`sourceHandle` values):** | Source node type | sourceHandle | Effect | |------------------|--------------|--------| | Any linear node | _(omit)_ | Sets `source.next = targetId` | | `choice` | `option-0`, `option-1`, … | Sets `options[i].next` | | `conditional` | `branch-0`, `branch-1`, … | Sets `branches[i].next` | | `conditional` | `fallback` | Sets `fallback` | | `scene_change` | `next-scene` | Sets `nextScene` to target scene path | **Example — wire choice branch:** ```json { "tool": "connect_nodes", "arguments": { "scenePath": "day4/rooftop", "sourceId": "main_choice", "targetId": "kol_path", "sourceHandle": "option-0" } } ``` **Example — link to next scene:** ```json { "tool": "connect_nodes", "arguments": { "scenePath": "day4/rooftop", "sourceId": "scene_end", "targetId": "day5/finale", "sourceHandle": "next-scene" } } ``` --- ### Validation Run after building or editing a scene to catch broken `next` pointers, unreachable nodes, and missing assets. #### `validate_scene` Validate one scene file on disk. | Argument | Type | Required | Description | |----------|------|----------|-------------| | `scenePath` | string | yes | e.g. `day1/luncheon` | **Returns:** ```json { "ok": true, "scenePath": "day1/luncheon", "passed": false, "issues": [ { "severity": "error", "rule": "connectivity", "message": "Node \"intro\" has no next pointer — player may get stuck.", "sceneId": "luncheon", "nodeId": "intro" } ], "stats": { "totalScenes": 1, "totalNodes": 12, "totalEdges": 11, ... } } ``` **HTTP (REST shim or direct):** ```bash curl -s -X POST https://szn.zone/mcp \ -H 'Content-Type: application/json' \ -d '{"tool":"validate_scene","arguments":{"scenePath":"day1/luncheon"}}' # Or dev API: curl -s 'http://szn.zone:5173/api/validate-scene?scenePath=day1/luncheon' curl -s -X POST http://szn.zone:5173/api/validate-scene \ -H 'Content-Type: application/json' \ -d '{"scenePath":"day1/luncheon"}' ``` #### `validate_project` Validate all scenes under `src/scripts/`. | Argument | Type | Required | |----------|------|----------| | _(none)_ | | | **Returns:** Same shape as `validate_scene` but `passed` covers the whole project; includes `scenePaths` array. --- ### Content generation #### `generate_dialogue` Generate a single dialogue line via LLM. Requires `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` on the server. | Argument | Type | Required | Description | |----------|------|----------|-------------| | `character` | string | yes | Character id or display name | | `sceneContext` | string | yes | What's happening in the scene | | `tone` | string | no | e.g. `casual`, `cold`, `flirty` | | `precedingLines` | string[] | no | Prior lines for continuity | **Returns:** `{ "text": "..." }` — use in `add_node` or `update_node` dialogue `text` field. --- #### `generate_image_prompt` Generate a Stable Diffusion / Midjourney prompt for art. | Argument | Type | Required | Description | |----------|------|----------|-------------| | `type` | string | yes | `"background"` or `"character"` | | `sceneDescription` | string | no | For backgrounds | | `characterId` | string | no | For character sprites | | `expression` | string | no | For character sprites | **Returns:** `{ "prompt": "..." }` --- ### Asset management Assets live in `public/assets/{category}/` and are served at `/assets/{category}/{key}.{ext}`. | Category | Folder | Extensions | Used by | |----------|--------|------------|---------| | `bg` | `public/assets/bg/` | png, jpg, webp | Scene `background`, `scene_change.background` | | `fg` | `public/assets/fg/` | png, jpg, webp | `fg_show.image` | | `chars` | `public/assets/chars/` | png only | `char_enter`, `char_expression`, dialogue `expression` | | `bgm` | `public/assets/bgm/` | mp3, ogg, wav | Scene `bgm`, `bgm_change.track` | | `sfx` | `public/assets/sfx/` | mp3, ogg, wav | `sfx.sound` | | `ambience` | `public/assets/ambience/` | mp3, ogg, wav | Ambient loops (optional) | | `ui` | `public/assets/ui/` | png, jpg, webp | UI preload | **Asset keys** are filename without extension: `apartment_morning` → `/assets/bg/apartment_morning.png`. #### `list_assets` | Argument | Type | Required | Description | |----------|------|----------|-------------| | `category` | string | no | Filter: `bg`, `fg`, `chars`, `bgm`, `sfx`, `ambience`, `ui` | | `prefix` | string | no | Keys starting with prefix (e.g. `kol_` for Kiara sprites) | **Returns:** `{ "assets": [{ "key", "category", "extension", "url", "size", "meta?" }] }` **Example — list sprites for character kol:** ```json { "tool": "list_assets", "arguments": { "category": "chars", "prefix": "kol_" } } ``` HTTP: `GET /api/assets?category=chars&prefix=kol_` --- #### `upload_asset` Write an asset file to disk. | Argument | Type | Required | Description | |----------|------|----------|-------------| | `category` | string | yes | See table above | | `key` | string | yes | Asset key (sanitized to `[a-zA-Z0-9_-]`) | | `data` | string | yes | **Base64-encoded** file bytes | | `extension` | string | no | `png`, `mp3`, etc. (default `png`) | | `meta` | object | no | For `fg` only: `{ zIndex, x, y, scale }` | **Character sprite naming:** key = `{charId}_{expression}` → file `kol_excited.png` → use as `sprite` on `char_enter` / `char_expression` / `dialogue` nodes. **Sprite resolution order:** if `sprite` is set → `/assets/chars/{sprite}.png`; else `/assets/chars/{char}_{expression}.png`. **Example — upload background:** ```json { "tool": "upload_asset", "arguments": { "category": "bg", "key": "miami_rooftop_night", "extension": "png", "data": "" } } ``` **Example — upload BGM:** ```json { "tool": "upload_asset", "arguments": { "category": "bgm", "key": "rooftop_jazz", "extension": "mp3", "data": "" } } ``` **Returns:** `{ "ok": true, "asset": { "key", "category", "url", ... } }` --- #### `delete_asset` | Argument | Type | Required | |----------|------|----------| | `category` | string | yes | | `key` | string | yes | --- #### `add_character_expression` Register a new expression name in `src/characters/definitions.ts`. Upload the sprite separately via `upload_asset`. | Argument | Type | Required | Description | |----------|------|----------|-------------| | `characterId` | string | yes | `kol`, `cmo`, `larper`, or `model` | | `expression` | string | yes | e.g. `wink` (sanitized to lowercase snake_case) | Then upload: `upload_asset` with `category: "chars"`, `key: "kol_wink"`. --- ## Scene JSON format **File path:** `src/scripts/{folder}/{name}.json` **Scene path** (used in MCP): `{folder}/{name}` — no `.json` extension. ### TypeScript `Scene` type (`src/scripts/types.ts`) ```typescript interface Scene { id: string; background: string; bgm?: string; day?: number; // Story day; inferred from path if omitted sortOrder?: number; // Order within day (0-based); auto on create_scene if omitted description?: string; // Builder sidebar label nodes: ScriptNode[]; } ``` ### Canonical JSON example ```json { "id": "luncheon", "day": 1, "sortOrder": 1, "background": "convention_hall_day", "bgm": "chill_lounge", "description": "Convention luncheon meet-cute", "nodes": [ ... ] } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | string | yes | Short scene identifier (unique per file; often matches filename) | | `day` | number | **yes*** | Story day (`1`, `2`, `3`…; use `99` for endings). *Inferred from folder if omitted | | `sortOrder` | number | **yes*** | Order within the day (0-based). Builder sidebar + `list_scenes` sort by `day` then `sortOrder`. *Auto-assigned on `create_scene` if omitted | | `background` | string | yes | BG asset key (must exist in `public/assets/bg/`) | | `bgm` | string | no | BGM asset key played on scene load | | `description` | string | no | Short label for the builder sidebar | | `nodes` | array | yes | Ordered list of script nodes | ### Sorting & folder conventions | Folder pattern | `day` value | Notes | |----------------|-------------|-------| | `day1/`, `day2/`, `day3/`, … | `1`, `2`, `3`, … | Match folder number | | `endings/` | `99` | All endings share day 99; use `sortOrder` to order them | **Sort key:** `day` ascending → `sortOrder` ascending → path alphabetically. **Always set `day` and `sortOrder` explicitly** when writing JSON by hand or generating files. Do not rely on a central registry — the builder reads these fields directly from each file. **Picking `sortOrder`:** Use `list_scenes` to see existing scenes on that day, then pick the next integer. Example: if Day 2 has `sortOrder` 0 and 2, insert between them with `sortOrder: 1` or append with `3`. ### Current scene inventory (reference) | path | day | sortOrder | id | |------|-----|-----------|-----| | `day1/opening` | 1 | 0 | opening | | `day1/luncheon` | 1 | 1 | luncheon | | `day1/beach_walk` | 1 | 2 | beach_walk | | `day2/opening` | 2 | 0 | opening | | `day2/convention` | 2 | 1 | convention | | `day2/pool_party` | 2 | 2 | pool_party | | `day3/fight_night` | 3 | 0 | fight_night | | `endings/alone_ending` | 99 | 0 | alone_ending | | `endings/cmo_ending` | 99 | 1 | cmo_ending | | `endings/kol_ending` | 99 | 2 | kol_ending | | `endings/larper_ending` | 99 | 3 | larper_ending | | `endings/model_ending` | 99 | 4 | model_ending | Re-run `list_scenes` before authoring — this table may be stale after you add scenes. **Day folders:** Use `day1/`, `day2/`, `day3/`, etc. **Endings:** Use `endings/{name}` with `"day": 99`. **Cross-scene navigation:** `scene_change` node with `"nextScene": "day2/pool_party"` (path without `.json`). ### HTTP: list all scenes from disk Agents can read every scene file without MCP: ```bash curl -s 'http://szn.zone:5173/api/scenes' # → { "ok": true, "scenes": [{ "path": "day1/opening", "scene": { ...full Scene... } }, ...] } ``` The builder uses this endpoint on load (not a static file list), so **new JSON files appear after refresh** without editing any TypeScript config. --- ## Node types — complete JSON schemas Every node requires `"id"` (unique within the scene) and `"type"`. Linear nodes use `"next": "node_id"` or `"next": null` to end the scene. ### TypeScript `NodeType` union (`src/scripts/types.ts`) ``` 'dialogue' | 'choice' | 'conditional' | 'scene_change' | 'char_enter' | 'char_exit' | 'char_expression' | 'sfx' | 'bgm_change' | 'narration' | 'phone_notification' | 'fg_show' | 'fg_hide' | 'screen_effect' ``` All nodes extend `BaseNode`: `{ id: string; type: NodeType; next?: string | null }`. ### `narration` Narrator text, no speaker portrait. ```json { "id": "intro", "type": "narration", "text": "Day 1 — Open Bar Luncheon. The hall is packed.", "next": "survey" } ``` | Field | Type | Required | |-------|------|----------| | `text` | string | yes | | `next` | string \| null | no | --- ### `dialogue` Character speaks. Use `char_enter` before first line of a character in a scene. ```json { "id": "kol_1", "type": "dialogue", "speaker": "kol", "expression": "excited", "sprite": "kol_excited", "text": "Oh my god, are you recording too?", "next": "kol_2" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `speaker` | string \| null | yes | Character id: `kol`, `cmo`, `larper`, `model` | | `text` | string | yes | Dialogue line (1–3 sentences typical) | | `expression` | string | no | Expression name (game state); used if `sprite` omitted | | `sprite` | string | no | Explicit chars/ asset key (e.g. `kol_excited`) | | `next` | string \| null | no | | --- ### `choice` Player picks an option. Each option has its own `next` target. ```json { "id": "first_choice", "type": "choice", "options": [ { "text": "Approach the ring-light girl", "next": "kol_path_start", "effects": { "affinity": { "kol": 2 }, "flags": { "approached_kol_first": true }, "time": 1 } }, { "text": "Check out the project booth", "next": "cmo_path_start", "effects": { "flags": { "approached_cmo_first": true } } } ] } ``` | Field | Type | Required | |-------|------|----------| | `options` | array | yes | | `options[].text` | string | yes | Button label | | `options[].next` | string | yes | Target node id | | `options[].effects` | object | no | See Game state below | | `options[].condition` | object | no | Hide option if unmet: `{ "flag": "...", "minAffinity": { "char": "kol", "value": 5 } }` | **Note:** `choice` nodes do not use top-level `next` for branching — wire each `options[i].next` via `connect_nodes` with `sourceHandle: "option-N"`. --- ### `conditional` Branch based on flags or affinity. Evaluated **instantly** (no click) when the node is reached; first matching branch wins; else `fallback`. **Auto flags:** `char_enter` sets `met_{charId}` (e.g. `met_model`, `met_kol`) in game state — use in conditionals for “did the player meet this character earlier?” **Playtest note:** `?scene=day1/beach_walk` resets all flags. To test a branch, add `&flags=met_model,approached_model_first` (comma-separated). ```json { "id": "route_check", "type": "conditional", "branches": [ { "condition": { "flag": "cmo_vip_invite", "flagValue": true }, "next": "vip_scene" }, { "condition": { "minAffinity": { "char": "kol", "value": 10 } }, "next": "kol_bonus" } ], "fallback": "default_path" } ``` | Field | Type | Required | |-------|------|----------| | `branches` | array | yes | | `branches[].condition.flag` | string | no | | `branches[].condition.flagValue` | boolean | no (default true) | | `branches[].condition.minAffinity` | `{ char, value }` | no | | `branches[].next` | string | yes | | `fallback` | string | yes | Wire branches via `connect_nodes` with `sourceHandle: "branch-0"`, `"branch-1"`, or `"fallback"`. --- ### `scene_change` Change background and/or load a different scene file. ```json { "id": "to_luncheon", "type": "scene_change", "background": "convention_hall_day", "transition": "fade", "duration": 1000, "next": "arrive" } ``` ```json { "id": "to_next_scene", "type": "scene_change", "nextScene": "day2/pool_party" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `background` | string | no | New BG asset key | | `transition` | string | no | `fade`, `dissolve`, or `cut` | | `duration` | number | no | Ms (default 800) | | `nextScene` | string | no | Scene path to load (cross-file) | | `next` | string \| null | no | Next node in **same** scene (if not using `nextScene`) | --- ### `char_enter` Show character sprite on screen. **Required before a character's first dialogue in a scene.** ```json { "id": "kol_enter", "type": "char_enter", "char": "kol", "position": "center", "expression": "excited", "sprite": "kol_excited", "transition": "slide", "next": "kol_1" } ``` | Field | Type | Required | Values | |-------|------|----------|--------| | `char` | string | yes | `kol`, `cmo`, `larper`, `model` | | `position` | string | yes | `left`, `center`, `right` | | `expression` | string | no | Expression name; default `neutral` | | `sprite` | string | no | **Sprite to display** — chars/ asset key (e.g. `kol_excited`). Overrides `{char}_{expression}` lookup. Upload via `upload_asset` first; list via `list_assets` with `prefix: "{char}_"`. | | `transition` | string | no | `slide` or `fade` | --- ### `char_exit` Remove character from screen. ```json { "id": "kol_leave", "type": "char_exit", "char": "kol", "transition": "slide", "next": "after_kol" } ``` --- ### `char_expression` Change expression without new dialogue (mid-scene). ```json { "id": "kol_blush", "type": "char_expression", "char": "kol", "expression": "flirty", "sprite": "kol_flirty", "next": "kol_next_line" } ``` | Field | Type | Required | |-------|------|----------| | `char` | string | yes | | `expression` | string | yes | | `sprite` | string | no — explicit chars/ asset key; overrides `{char}_{expression}` | | `next` | string \| null | no | --- ### `bgm_change` Start, change, or stop background music. ```json { "id": "start_bgm", "type": "bgm_change", "track": "chill_lounge", "fadeIn": 1500, "next": "intro" } ``` ```json { "id": "stop_bgm", "type": "bgm_change", "track": null, "fadeOut": 2000, "next": "quiet_scene" } ``` | Field | Type | Description | |-------|------|-------------| | `track` | string \| null | BGM key or `null` to stop | | `fadeIn` | number | Fade-in ms | | `fadeOut` | number | Fade-out ms | --- ### `sfx` Play a one-shot sound effect. ```json { "id": "phone_buzz", "type": "sfx", "sound": "phone_buzz", "next": "check_phone" } ``` --- ### `phone_notification` Show a phone/Telegram notification overlay. ```json { "id": "tg_notif", "type": "phone_notification", "sender": "GoonGame Official", "group": "GoonGame Convention 2026", "message": "You're invited! VIP pass confirmed.", "next": "decide" } ``` --- ### `fg_show` / `fg_hide` Show or hide a foreground overlay image (sparkles, props, etc.). ```json { "id": "show_sparkles", "type": "fg_show", "image": "sparkles", "layerId": "sparkles", "zIndex": 20, "x": 0, "y": 0, "scale": 1, "transition": "fade", "next": "dialogue_with_sparkles" } ``` ```json { "id": "hide_sparkles", "type": "fg_hide", "layerId": "sparkles", "transition": "fade", "next": "continue" } ``` --- ### `screen_effect` Camera/screen effect. ```json { "id": "quake", "type": "screen_effect", "effect": "shake", "duration": 0.5, "intensity": 1, "next": "after_shake" } ``` | Field | Type | Values | |-------|------|--------| | `effect` | string | `shake`, `zoom_in`, `zoom_out`, `zoom_reset` | | `duration` | number | 0.1–3.0 seconds | | `intensity` | number | Optional multiplier (default 1) | --- ## TypeScript types — full reference (`src/scripts/types.ts`) Use this when generating JSON by hand. Field names and types must match exactly. ### `Scene` ```typescript interface Scene { id: string; background: string; bgm?: string; day?: number; // infer: dayN/ → N, endings/ → 99 sortOrder?: number; // 0-based order within day description?: string; nodes: ScriptNode[]; } ``` ### `ScriptNode` (discriminated union on `type`) | type | Key fields (besides `id`, `next?`) | |------|-------------------------------------| | `narration` | `text: string` | | `dialogue` | `speaker: string \| null`, `text: string`, `expression?`, `sprite?` | | `choice` | `options: ChoiceOption[]` (no top-level `next` for branches) | | `conditional` | `branches: { condition, next }[]`, `fallback: string` | | `scene_change` | `background?`, `transition?`, `duration?`, `nextScene?` | | `char_enter` | `char`, `position: 'left'\|'center'\|'right'`, `expression?`, `sprite?`, `transition?` | | `char_exit` | `char`, `transition?` | | `char_expression` | `char`, `expression`, `sprite?` | | `bgm_change` | `track: string \| null`, `fadeIn?`, `fadeOut?` | | `sfx` | `sound: string` | | `phone_notification` | `sender`, `message`, `group?` | | `fg_show` | `image`, `layerId?`, `zIndex?`, `x?`, `y?`, `scale?`, `transition?` | | `fg_hide` | `layerId?`, `image?`, `transition?` | | `screen_effect` | `effect: 'shake'\|'zoom_in'\|'zoom_out'\|'zoom_reset'`, `duration`, `intensity?` | ### `ChoiceOption` ```typescript interface ChoiceOption { text: string; next: string; effects?: { affinity?: Partial>; flags?: Record; time?: number; }; condition?: { flag?: string; minAffinity?: { char: string; value: number }; }; } ``` ### `sceneMetadata.ts` helpers (used by builder + MCP) | Function | Purpose | |----------|---------| | `inferDayFromPath(path)` | `day1/foo` → `1`, `endings/bar` → `99` | | `resolveSceneMetadata(path, scene)` | Returns `{ day, sortOrder, event, description }` with fallbacks | | `compareSceneFiles(a, b)` | Sort comparator: day → sortOrder → path | Agents do **not** call these directly — they are documented so you understand how `list_scenes` ordering works. --- | id | displayName | personality | defaultExpression | |----|-------------|-------------|-------------------| | `kol` | Kiara | KOL egirl, attention farmer | `neutral` | | `cmo` | Sora | Ice-cold CMO, tsundere | `professional_smile` | | `larper` | Mika | Crypto larper, ditzy | `ditzy` | | `model` | Lena | Fight Night promo model | `neutral` | ### Registered expressions (sprites must exist as `chars/{id}_{expression}.png`) **kol:** `neutral`, `excited`, `flirty`, `scheming`, `annoyed`, `laughing`, `phone_selfie`, `surprised` **cmo:** `neutral`, `professional_smile`, `cold`, `tsundere_blush`, `annoyed`, `genuine_smile`, `flustered`, `serious` **larper:** `neutral`, `ditzy`, `flirty`, `pouty`, `scheming`, `impressed`, `bored`, `clingy` **model:** `neutral`, `curious`, `confused`, `genuine_laugh`, `shy`, `interested`, `playful`, `blushing` Use `add_character_expression` + `upload_asset` for new expressions. --- ## Game state (affinity, flags, time) Set via choice `effects`: ```json "effects": { "affinity": { "kol": 2, "cmo": -1 }, "flags": { "cmo_vip_invite": true, "hit_buffet_first": true }, "time": 1 } ``` | Effect | Description | |--------|-------------| | `affinity.{char}` | Integer delta. Good ending threshold: **≥ 15**. Neutral: **≥ 8**. | | `flags.{name}` | Boolean story flag for `conditional` nodes | | `time` | Spends time slots (each day has **4** slots; `TOTAL_DAYS = 3`) | Characters: only `kol`, `cmo`, `larper`, `model` have affinity tracking. --- ## Authoring rules 1. **Every scene JSON must have `day` and `sortOrder`** — set explicitly when generating files; `create_scene` fills them if you omit. 2. **`char_enter` before first dialogue** of each character in a scene. 3. **Chain linear flow** with `"next": "node_id"` or use `connect_nodes`. 4. **End scenes** with `"next": null` on the last node (or cross-scene via `scene_change.nextScene`). 5. **Asset keys must exist** before referencing in scenes — `list_assets` to verify. 6. **Node ids** must be unique within a scene. Use descriptive ids: `kol_choice_1`, `cmo_path_start`. 7. **Scene paths** use forward slashes: `day2/pool_party`, not `day2\pool_party`. 8. **MCP writes to disk immediately** — no separate export step needed for agents. 9. **After adding a scene file**, call `list_scenes` to confirm `day`/`sortOrder`; builder picks it up on refresh via `GET /api/scenes`. 10. **Do not edit `sceneSort.ts` or any scene registry** — ordering is entirely JSON-driven. ### Minimal new-scene template (copy-paste) ```json { "id": "my_scene", "day": 4, "sortOrder": 0, "description": "One-line summary for the builder", "background": "existing_bg_key", "bgm": "optional_bgm_key", "nodes": [ { "id": "start", "type": "narration", "text": "Opening line.", "next": null } ] } ``` Save as `src/scripts/day4/my_scene.json` → scene path `day4/my_scene`. --- ## Recipe: Create a whole new day Example: create Day 4 with two scenes, assets, branching paths, and cross-scene links. ### Phase 1 — Inventory ``` list_scenes → see existing days list_assets → see available bg/bgm/chars get_scene day3/fight_night → study how prior day ends ``` ### Phase 2 — Generate & upload assets ``` generate_image_prompt { type: "background", sceneDescription: "Miami rooftop bar at sunset, crypto convention afterparty" } → generate image externally → upload_asset { category: "bg", key: "rooftop_sunset", extension: "png", data: "..." } generate_image_prompt { type: "background", sceneDescription: "Hotel suite morning, messy room, convention badges on table" } → upload_asset { category: "bg", key: "hotel_suite_morning", ... } # BGM — generate music externally, then: upload_asset { category: "bgm", key: "rooftop_jazz", extension: "mp3", data: "..." } # New expression if needed: add_character_expression { characterId: "kol", expression: "tipsy" } upload_asset { category: "chars", key: "kol_tipsy", extension: "png", data: "..." } ``` ### Phase 3 — Create scenes ``` # create_scene auto-sets day=4 and sortOrder if omitted; explicit is clearer: create_scene { scenePath: "day4/rooftop", id: "rooftop", day: 4, sortOrder: 0, description: "Rooftop afterparty", background: "rooftop_sunset", bgm: "rooftop_jazz" } create_scene { scenePath: "day4/morning_after", id: "morning_after", day: 4, sortOrder: 1, background: "hotel_suite_morning" } list_scenes → confirm day/sortOrder ordering ``` ### Phase 4 — Build scene graph (day4/rooftop) Add nodes in order, chaining with `afterNodeId`: ``` add_node { scenePath: "day4/rooftop", node: { id: "start", type: "bgm_change", track: "rooftop_jazz", fadeIn: 1500, next: null } } add_node { scenePath: "day4/rooftop", afterNodeId: "start", node: { id: "intro", type: "narration", text: "Day 4. The rooftop bar...", next: null } } add_node { scenePath: "day4/rooftop", afterNodeId: "intro", node: { id: "kol_enter", type: "char_enter", char: "kol", position: "center", expression: "tipsy", sprite: "kol_tipsy", transition: "slide", next: null } } # Generate dialogue: generate_dialogue { character: "kol", sceneContext: "Rooftop afterparty, she's had a few drinks", tone: "flirty" } → use returned text in add_node dialogue add_node { scenePath: "day4/rooftop", afterNodeId: "kol_enter", node: { id: "kol_line_1", type: "dialogue", speaker: "kol", expression: "tipsy", text: "", next: null } } add_node { scenePath: "day4/rooftop", afterNodeId: "kol_line_1", node: { id: "rooftop_choice", type: "choice", options: [ { "text": "Buy her another drink", "next": "", "effects": { "affinity": { "kol": 3 }, "time": 1 } }, { "text": "Suggest calling it a night", "next": "", "effects": { "affinity": { "kol": -1 } } } ] }} ``` Wire choice branches: ``` connect_nodes { scenePath: "day4/rooftop", sourceId: "rooftop_choice", targetId: "drink_path", sourceHandle: "option-0" } connect_nodes { scenePath: "day4/rooftop", sourceId: "rooftop_choice", targetId: "night_path", sourceHandle: "option-1" } # Add branch content nodes, then end scene: add_node { scenePath: "day4/rooftop", node: { id: "drink_path", type: "dialogue", speaker: "kol", expression: "laughing", text: "...", next: "scene_end" } } add_node { scenePath: "day4/rooftop", node: { id: "scene_end", type: "scene_change", nextScene: "day4/morning_after", next: null } } ``` ### Phase 5 — Link from previous day Find the last node of day 3 and add cross-scene link: ``` get_scene day3/fight_night update_node { scenePath: "day3/fight_night", nodeId: "", patch: { next: null } } # Or add a scene_change node pointing to day4/rooftop add_node { scenePath: "day3/fight_night", afterNodeId: "", node: { id: "to_day4", type: "scene_change", nextScene: "day4/rooftop" } } ``` ### Phase 6 — Verify ``` validate_scene { scenePath: "day4/rooftop" } get_scene day4/rooftop → check all next pointers resolve list_assets → confirm bg/bgm/sprites exist ``` If `passed: false`, fix `issues` (usually missing `connect_nodes` or dead-end nodes with `next: null`). Playtest: `http://szn.zone:5173/?scene=day4/rooftop&node=start` --- ## Recipe: Audio workflow Music and SFX are **not generated server-side**. Workflow: 1. Compose/generate audio externally (Suno, Udio, etc.). 2. `upload_asset` with `category: "bgm"` or `"sfx"`. 3. Reference in scene: - Scene-level: `create_scene` / `update_scene` with `bgm: "rooftop_jazz"` - Mid-scene: `bgm_change` node with `track: "rooftop_jazz"` - One-shot: `sfx` node with `sound: "phone_buzz"` --- ## Recipe: Branching with flags and affinity ``` # Set flag via choice effect: add_node { ..., node: { id: "invite_choice", type: "choice", options: [ { "text": "Accept VIP invite", "next": "", "effects": { "flags": { "cmo_vip": true }, "affinity": { "cmo": 3 } } }, { "text": "Decline politely", "next": "", "effects": { "affinity": { "cmo": -1 } } } ]}} # Later, branch on flag: add_node { ..., node: { id: "vip_gate", type: "conditional", branches: [ { "condition": { "flag": "cmo_vip", "flagValue": true }, "next": "" } ], "fallback": "" }} connect_nodes { sourceId: "vip_gate", targetId: "vip_scene", sourceHandle: "branch-0" } connect_nodes { sourceId: "vip_gate", targetId: "normal_scene", sourceHandle: "fallback" } ``` --- ## Builder UI (optional, for humans) - **URL:** `http://szn.zone:5173/builder.html` - Visual graph editor, validation, asset upload panel - **Scene list** is sorted by `day` + `sortOrder` from each JSON file (not a hardcoded list) - **On load:** always reads `GET /api/scenes` from disk — **no localStorage cache** (stale cache was removed) - **Reload from Disk** toolbar button: re-fetch all scenes from `src/scripts/` - **Connecting nodes** in the graph immediately writes `next` / choice targets to `src/scripts/{scene}.json` on disk - **Inspector edits** to day, sortOrder, description, background, bgm persist to the JSON file automatically - **Export JSON** writes all scenes including `day` and `sortOrder` - MCP / direct JSON edits appear in builder after page refresh - **Playtest:** `http://szn.zone:5173/?scene={path}&node={nodeId}` ### Dev HTTP APIs (besides MCP) | Endpoint | Method | Purpose | |----------|--------|---------| | `/api/scenes` | GET | List all scene JSON from disk `{ ok, scenes: [{ path, scene }] }` | | `/api/validate-scene?scenePath=…` | GET | Validate one scene | | `/api/validate-scene` | POST | `{ "scenePath": "…" }` | | `/api/export-scenes` | POST | Write scene files `{ files: [{ path, content }] }` | | `/api/assets` | GET | List/upload assets | ## Project layout ``` goongame/ ├── llms.txt # This file — https://szn.zone/llms.txt ├── src/scripts/ # Scene JSON (game + builder read these) │ ├── types.ts # Scene + ScriptNode TypeScript types │ ├── sceneMetadata.ts # day/sortOrder inference + sort helpers │ ├── day1/, day2/, day3/, endings/ ├── src/characters/definitions.ts ├── public/assets/ # bg, fg, chars, bgm, sfx, ambience, ui └── builder/mcp/ # MCP server + /api/scenes handler ``` ## Environment variables (server-side LLM tools) ```bash export OPENAI_API_KEY=sk-... # or ANTHROPIC_API_KEY export GOONGAME_LLM_PROVIDER=openai # or anthropic export OPENAI_MODEL=gpt-4o-mini # optional ``` Without keys, `generate_dialogue` returns a placeholder string.