Monorepo for Aesthetic.Computer aesthetic.computer
core reports renpy-adaptation.md
17 kB

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" or character "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. jump is a goto (no stack); call/return push/pop a call stack so shared scenes can be reused. Labels can take parameters; return can carry a value (_return).
  • menus — a list of caption → block pairs. Choices take an optional if guard (hidden when false) and a set clause (hide already-picked choices). Blocks set flags ($ drank_tea = True) and usually end in jump.
  • variables/flags — default declares saveable state; $ runs a line of Python; if/else branches 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 via characters({...}). 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.mjs script-runner and AC's paint.

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, seed flags from defaults, set pc to script.start, then run() until the first parking step (a say or menu). Optionally store.retrieve a save and resume.
  • sim() — the feel loop, lifted verbatim from besospesos: typed += speed typewriter, 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 face spec — drawCEO generalizes into the engine's default portrait painter, with a script-level drawPortrait override 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. Rebuilds layout.hits each frame for act to 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 (onBail hook → besospesos's "walking out counts as a dud"). Every act that moves the pc calls run() — 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.rich for content that only unlocks on a second run) and Ren'Py's menu set behavior (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 a paint hook.
  • 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 on show covers two-character scenes; that's the whole positioning system.
  • Side images, say-attributes, image tag/attribute algebra. Collapses to mood on 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 .vn text 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.