Ren'Py → AC: adapting the visual-novel idea to the piece stack #
2026.06.10 — research report. Grounding: disks/CLAUDE.md (lifecycle), disks/besospesos.mjs (first consumer), lib/nom.mjs (shared-engine pattern), disks/marimbaba.mjs + lib/pop.mjs (manifest-driven wrapper), HAND.md (gradient: libs are the instrument, leaves stay small).
1. A short tour of Ren'Py's architecture #
Ren'Py is three layers that are worth keeping mentally separate, because we only want one and a half of them.
1a. The script layer (the part worth stealing) #
Ren'Py's script language is a flat sequence of statements grouped under labels, with a tiny control-flow vocabulary:
define s = Character('Sylvie', color="#c8ffc8")
default book = False
label start:
s "Hello!"
menu:
"Take the book":
$ book = True
jump library
"Leave" if not shy:
jump exit
label library:
if book:
"You chose the book path."
return
The whole narrative model is:
- say —
"narration"orcharacter "dialogue". One statement = one click. This is the engine's heartbeat: execution parks on a say until the player advances. - characters —
define s = Character(...)binds a short name to a display name + styling, so dialogue lines stay terse. - labels — named entry points.
jumpis a goto (no stack);call/returnpush/pop a call stack so shared scenes can be reused. Labels can take parameters;returncan carry a value (_return). - menus — a list of caption → block pairs. Choices take an optional
ifguard (hidden when false) and asetclause (hide already-picked choices). Blocks set flags ($ drank_tea = True) and usually end injump. - variables/flags —
defaultdeclares saveable state;$runs a line of Python;if/elsebranches on it.
The minimal viable subset — what every Ren'Py game actually uses — is: characters, labels, say, menu (with guards + flag effects), flags, if, jump. Call/return is the first optional extra; everything else (screen language, ATL, transforms, Python escape hatch) is elaboration.
1b. The presentation layer (mostly skip) #
A retained scene graph: scene bg meadow clears a layer, show sylvie green smile at left adds/replaces a tagged sprite (tag + attribute system, so show sylvie sad swaps the expression on the existing sprite), hide removes, with dissolve transitions between interaction snapshots. Layers (master/transient/screens/overlay), zorder, ATL (a mini-language for animating transforms), side images next to the dialogue box, and a "screen language" for building UI declaratively.
This is all in tension with AC: our graphics are immediate-mode — paint redraws everything each frame, there is no retained displayable tree, and pieces like besospesos already draw parametric pixel portraits with ink().box() instead of sprite PNGs. The concepts that survive translation are: a stage with named slots (who's on screen, where, with what mood), a dialogue box with a typewriter, and a choice list. The retained-graph machinery does not.
1c. Persistence (two tiers, both map cleanly) #
- Saves: a snapshot of (current statement, displayed images, music, all
default-declared + mutated variables). Rollback is automatic checkpointing of that snapshot at every interaction, with custom "revertable" list/dict/set types so mutation is undoable. - Persistent: a separate
persistent.*namespace that survives across playthroughs — gallery unlocks, endings seen, settings — merged by recency when synced across machines.
The save = {label, index, flags} insight is the key one: because execution parks on statement boundaries, a save is just a program counter plus a flag bag. Tiny, JSON-able, perfect for store.
2. What the JS adaptations kept and dropped #
- Monogatari (closest cousin) — kept Ren'Py's statement-stream model but encoded it as a JS object:
script({ 'Start': [ 'y:happy Hi! I am Yui.', {'Choice': …}, 'jump End' ] })— labels are keys, statements are strings with a micro-syntax ('y:happy text') or objects for structured actions. Characters defined separately viacharacters({...}). Conditionals are inline JS functions reading a storage object. Lesson: a JS data structure is a perfectly good Ren'Py script; the string micro-syntax keeps dialogue lines as terse as Ren'Py's. It dropped ATL and the screen language entirely; presentation is CSS. - RenJS — YAML scripts on top of PhaserJS. Kept labels/choices/scene/show/music; dropped Python and the screen language; pitched at "writers, not programmers." Lesson: a declarative file works, but YAML bought them a parser dependency and a second syntax without buying expressiveness — guards and effects get awkward strings.
- Ink / inkjs — the other pole: a presentation-agnostic narrative engine. Knots/stitches (≈labels), diverts (≈jump), choices, variables, weave/gather to merge branches. The runtime hands the host app text + current choices and nothing else; the host owns all rendering. Lesson: the cleanest seam is "story runtime below, presentation above" — exactly the seam between a
lib/vn.mjsscript-runner and AC'spaint.
The composite lesson for AC: take Monogatari's "script is data" encoding, Ink's runtime/presentation seam, and Ren'Py's statement-parking execution model. Take nobody's renderer.
3. Proposed design: lib/vn.mjs #
A shared engine in the nom.mjs mold: module-level state, a reset() that re-zeros everything (the engine is a session singleton shared across vn pieces — same bleed-prevention comment nom carries), and the five lifecycle exports a wrapper forwards to. Pieces become thin wrappers that pass a script object at boot, marimbaba-style.
3a. The script format (JS object literal) #
// disks/besospesos.mjs — the whole piece is a script + a 12-line wrapper.
import * as vn from "../lib/vn.mjs";
const script = {
meta: { title: "besospesos", desc: "a ceo dating sim…" },
flags: { besos: 0, pesos: 0 }, // default-declared state (saveable)
meters: [ // HUD meters, drawn by the engine
{ flag: "besos", icon: "heart", col: [255, 90, 140], at: "left" },
{ flag: "pesos", icon: "$", col: [140, 230, 140], at: "right" },
],
cast: {
sam: { name: "sam altman", face: { skin: […], hair: […], style: "wavy", … } },
elon: { name: "elon musk", face: { … } },
},
start: "title",
labels: {
title: [
{ titleCard: ["besos", "pesos"], sub: "a ceo dating sim" },
{ jump: "select" },
],
select: [
{ menu: "who needs you today?", choices: [
{ label: "sam altman — his facetime keeps freezing",
if: (f) => !f.dated_sam, jump: "sam1" },
{ label: "elon musk — his smart house locked him out",
if: (f) => !f.dated_elon, jump: "elon1" },
{ if: (f) => f.dated_sam && f.dated_elon, jump: "fin", auto: true },
]},
],
sam1: [
{ show: "sam", mood: 0 },
["sam", "my facetime to shanghai keeps freezing. the board call is in four minutes."],
{ menu: { choices: [
{ label: "four minutes is plenty. let me ping it.",
set: { besos: +2 }, say: "he watches you type. 'you're so composed.'" },
{ label: "i charge rush rates. $800.",
set: { besos: -1, pesos: +800 }, say: "he wires it without blinking." },
]}},
// …two more questions…
{ if: (f) => f.sam_besos >= 5, then: "sam_kiss", else: "sam_invoice" },
],
fin: [ /* tally screen as a step type, or a custom paint hook */ ],
},
};
function boot($) { return vn.boot($, script); }
function paint($) { return vn.paint($); }
function sim($) { return vn.sim($); }
function act($) { return vn.act($); }
function leave($) { return vn.leave($); }
function meta() { return vn.meta(script); }
export { boot, paint, sim, act, leave, meta };
Step vocabulary — the Ren'Py minimal subset, one JS shape each:
| Ren'Py | vn.mjs step | notes |
|---|---|---|
s "text" |
["sam", "text"] or bare "narration" |
array = dialogue, string = narration; parks until advance |
label x: |
key in labels |
|
jump x |
{ jump: "x" } |
program counter reassignment |
call x / return |
{ call: "x" } / { return: true } |
tiny array as the stack; v2 if besospesos doesn't need it (it doesn't) |
menu: + if guard + $ flag |
{ menu, choices: [{ label, if, set, say, jump }] } |
set is a delta bag ({besos:+2}), say is the reply beat, guard hides |
default / $ x = v |
flags defaults + set deltas + { do: (f) => … } escape hatch |
|
if/else |
{ if: pred, then: label, else: label } |
predicates are plain functions over the flag bag |
show sylvie smile at left |
{ show: "sam", mood: 1, at: "center" } |
mood drives the parametric portrait, not sprite swaps |
scene bg |
{ scene: { wipe: [26,12,28] } } |
a wipe color / backdrop drawer, not an image |
with dissolve |
skip (v1) | a frame-counted crossfade later if ever wanted |
3b. How it maps onto boot/paint/sim/act #
The engine is a statement-parking interpreter: a program counter pc = { label, index, stack: [] } plus a phase ("typing" | "parked" | "choosing"), exactly the shape besospesos already has ad hoc (state/q/phase/typed).
- boot($, script) —
reset(), store the script, seedflagsfrom defaults, setpctoscript.start, thenrun()until the first parking step (a say or menu). Optionallystore.retrievea save and resume. - sim() — the feel loop, lifted verbatim from besospesos:
typed += speedtypewriter, scheduled-melody drain (melody.filter-style blip queue), floaters. No story logic lives here. - paint($) — immediate-mode redraw of the current parked state: backdrop wipe, the shown cast member (parametric portrait via the
facespec —drawCEOgeneralizes into the engine's default portrait painter, with a script-leveldrawPortraitoverride hook for pieces that want their own), name, meters HUD, word-wrapped typewriter line, and — when phase is "choosing" — the stacked outline-box choice buttons. Rebuildslayout.hitseach frame foractto hit-test, the besospesos/nom idiom. - act($) — advance/choose only: tap completes the typewriter, then advances
pc; arrows + enter / number keys / touch pick choices; escape = script-defined bail (onBailhook → besospesos's "walking out counts as a dud"). Every act that moves thepccallsrun()— which executes non-parking steps (jump, set, if, show, scene) eagerly until it hits the next say/menu. That eager-run loop is the entire interpreter; it should be ~30 lines.
run() parking on says/menus is what makes saves trivial and keeps paint pure: between interactions, nothing in the story moves.
3c. Persistence → store #
Two tiers, mirroring Ren'Py exactly, on the existing API (store[key] = …; store.persist(key, method) / await store.retrieve(key, method)):
- Save slot (Ren'Py save file):
store["vn:besospesos:save"] = { pc, flags, shown }, persisted with"local"(it's tiny JSON — localStorage is fine;"local:db"if a script ever carries blobs). Written automatically at every menu choice — choices are the natural checkpoint, the analogue of Ren'Py snapshotting at interaction boundaries.leave()persists too. boot offers resume if a save exists. - Persistent (Ren'Py
persistent.*):store["vn:besospesos:seen"] = { endings: {…}, choicesPicked: {…} }— survives resets, powers "new game+" guards (if: (f, p) => p.endings.richfor content that only unlocks on a second run) and Ren'Py'smenu setbehavior (graying choices you've picked in any playthrough).
Because the save is {pc, flags} and predicates are pure functions of flags, there's no pickling problem, no revertable-collection machinery — the whole reason Ren'Py's save system is complicated is that it snapshots arbitrary Python heap; ours snapshots a JSON bag by construction.
4. Script format recommendation #
Recommendation: JS object literals in the piece file (option A), with the structure kept JSON-clean except for predicate functions. This is the nom/besospesos lineage, not the pop/marimbaba one, and the difference is principled:
| A. JS object in the piece | B. JSON manifest (disks/vn/x.json + fetch) |
C. KidLisp DSL | |
|---|---|---|---|
| guards/conditions | plain closures over flags — full power, zero parser | strings to mini-eval ("besos>=5") — invents a worse language |
natural (if (>= besos 5) …) |
| authoring/loop-generation | best — one file, the loop already writes these (besospesos was generated this way) | okay, but two files per piece and a fetch in boot | needs new special forms in a 15.6k-line evaluator mid-rehandify |
| hot reload | piece save = reload, free | manifest fetch can cache stale | free |
| who it serves | us + the loop | external tooling that doesn't exist yet | KidLisp authors, eventually |
| precedent | nom games, besospesos | pop tracks (where the data really is pure data: notes, colors) | — |
pop.mjs earned its JSON because a track manifest is genuinely declarative — no conditionals, no predicates. A VN script is not: guards, branch predicates, and flag math want a real language, and the JSON versions of those ("if": "besos>=5 && !dated_sam") mean writing an expression parser — building a worse JS inside JSON. Skip it. If a piece's script grows big, it can move to a sibling disks/vn/besospesos.data.mjs and be imported — still option A, just split.
KidLisp is the right v2, not the v1. A (label …) / (say …) / (menu …) form-set riding the existing evaluator would make VN authoring available to $code pieces and the prompt — a genuinely AC-native move (Ren'Py's screenplay syntax is arguably more lisp-shaped than JS-shaped). But it means touching kidlisp.mjs (rehandify target #3, under-tested) and designing parking semantics inside an evaluator built for per-frame re-evaluation — execution that waits is a new concept there. The clean path: build lib/vn.mjs against JS step objects first; the step vocabulary above is trivially expressible as S-exprs later, and a thin (vn …) reader can compile KidLisp forms into the same step objects without the engine knowing. Design the step schema now as the stable IR; let formats compile to it (this is exactly Ink's compiler/runtime split).
5. Don't-build list #
- Rollback. Ren'Py's flagship feature and its biggest complexity tax (revertable collections, per-interaction heap snapshots). Our pieces are 5-minute arcade-VNs, not 20-hour reads; "esc bails the date" is the AC-native undo. If ever wanted: saving
{pc, flags}at each choice into a ring buffer gives back-button rollback in 10 lines — so deferring costs nothing. - Screen language. A declarative UI toolkit inside the engine. AC already is the UI toolkit —
paint+layout.hits+ui.Button. Custom screens are just steps with apainthook. - ATL / transforms / layers / zorder. Retained-mode animation language on a retained scene graph we don't have. Mood-parameterized portraits +
sim-driven floaters/pulses already read better on a 6px-font pixel screen than tweened sprite slides. A{ at: "left"|"center"|"right" }slot onshowcovers two-character scenes; that's the whole positioning system. - Side images, say-attributes, image tag/attribute algebra. Collapses to
moodon the parametric portrait. - Skip/auto-forward. Skip-seen-text requires per-statement seen-tracking for a feature that matters in 40k-word novels. Tap-to-complete-typewriter (already in besospesos) is the right amount of impatience support. Auto mode is a timer trivially added later if a piece wants kiosk playback.
- NVL mode, history/backlog, self-voicing, translations. Real Ren'Py features, zero AC pieces asking.
- Asset pipeline (image folders → names, archives). AC VNs are no-asset by conviction — parametric faces, bitmap hearts, synth blips. This is the identity of the form here, not a limitation.
- A custom script file format / parser of any kind. No
.vntext format, no YAML (RenJS's parser bought them little). JS objects now, KidLisp forms later — both already have parsers.
6. Sizing and the gradient #
Per HAND.md the engine is instrument-tier: human-owned, legible, one head. Everything in §3b exists in besospesos today in ad-hoc form (~250 of its 609 lines are engine: typewriter, advance gating, choice buttons, hit-test, melody scheduler, floaters, wrap, portrait painter). Hoisted and generalized, lib/vn.mjs should land around 400–500 lines — a third of nom. besospesos then becomes the marimbaba shape: a script object (~200 lines, almost all of it the writing — which is the point) plus the 8-line forwarding wrapper. The second consumer (any new date-able cast, a mystery, a choose-your-own anything) is a loop-generated leaf: paste a script, ship a piece.