forge/FORGE.md
2026-10-06 16:57:52 -05:00

5403 lines
330 KiB
Markdown

# Forge
The plan for Forge, Singe's authoring tool. Forge is distributed on its own,
beside the engine and never inside it, and is planned on its own for the same
reason: `PLAN.md` is the engine's. Its documentation is `docs/Forge.adoc`;
its code is `forge/`; its tests are `testScripts/scene52.singe`
onward.
Section 1 is the first design and what was built to it. Section 2 re-reads
that against the whole engine and lays out every kind of game. Section 3
records the owner's review of section 2 and what changed: nothing here owes
anything to what was built before, and the Sierra and LucasArts adventure is a
first-class target in 2D and in 3D.
## 1. Design: authoring tools (written 2026-09-13)
**The first slice is built (2026-09-13); see "What exists" at the end of this
section.** The rest of what follows is the design it was built to. The owner's
ask: "Singe-based game authoring tools to allow users with very little
programming skill the ability to create games", and, when the first sketch came
back too laserdisc-shaped: "laserdisc games are the 'old' style game. We also
need to think of the future" -- QTE games, hybrids like MACH3, 2D and 3D
platformers, shooters, adventure games, "and more".
### What the library says is actually hard
Counted rather than guessed, the way section 51 counted:
* `CrimePatrol/Script/hitbox-stripbar.singe` is **12,241 lines** of
`civillian[2741] = {295, 111, 307, 135}`. The largest artifact in a
laserdisc game is not code. It is data entry against a video.
* `ActionMax/38AmbushAlley.singe` is **14 lines** -- a gameID, five lengths, a
background frame, two thresholds, four sensor coordinates, and
`dofile(MYDIR .. "/Emulator.singe")`. A complete, shipping game. Five
ActionMax titles share the one engine.
So the repository already holds both the disease and the cure, and the cure is
the pattern section 51 started: the game kit lifted the *utility* boilerplate
out of 149 copies. This lifts the *structural* boilerplate, and what is left
is data.
### Do not model genre. Model layers.
The manual says it three times over: "the disc, the overlay, the GUIs, the 3D
scene and the particles". That is a composite of independent layers, and a
genre is only which of them a game uses:
```
light gun disc + overlay
QTE disc + GUI, rules on time windows
MACH3 disc + overlay + particles
2D platformer scene with physicsSet2D, or overlay
3D shooter scene + GUI + particles
adventure disc or scene, + GUI
```
The document model therefore mirrors the engine's layer model and the tool
never learns what a genre is. MACH3 stops being a hybrid special case and
becomes an ordinary two-layer document. This is the decision the whole design
rests on: a per-genre tool is N tools.
### Three nouns cover all of it
* **Entities** -- a thing on a layer with a position, a look (sprite, model,
text, video) and state.
* **Behaviours** -- reusable bundles attached to an entity, and where the
engine gets handed to non-programmers for nothing. `playerNew`,
`playerMove`, `playerJump`, `playerIsOnGround`, `playerSetStep`,
`playerSetSlope`, `playerSetSwim` already exist and are tested, so a
"Platformer" behaviour is a checkbox over a character controller that
works. So is "Follow Path" over `navAgentMoveTo`, and "Physics Body" over
`bodyNew`.
* **Rules** -- conditions to actions. The event sheet.
### The vocabulary is registered, not compiled in
Conditions, actions and behaviours are declared in a manifest -- name,
parameters, the Lua that implements them -- which the editor reads. Adding QTE
support is registering "time is within window" and "button pressed" plus a
couple of actions. **A genre is a pack, not an editor release**, and third
parties can write one. The same goes for layer types, asset types and views.
The test to apply to every later decision: *when this changes, is it data or is
it a release?*
### Blockly: considered, not first
Two problems. Blockly is browser technology and Singe has no web view and
should not grow one. A native block canvas is a great deal of UI work --
layout, snapping, hit-testing, scrolling -- and RmlUi is a document engine,
excellent at lists (the menu scrolls one) and unproven as a canvas.
The event sheet gets most of the benefit for a fraction of the cost because it
is a table. The move that keeps the door open: **rules are stored as
structure, never as UI layout**, so a block canvas can be added later as a
second view of the same data. Borrow the two things blocks are genuinely
better at -- nested condition groups with AND/OR, and a small safe expression
syntax with a picker (`score + 100`, `player.x < 50`). Rule lists that allow
only constants are what send people back to code.
### Compile to Lua; do not interpret
Walk the rules at build time and emit readable Lua, keeping the source data
beside it in the `.game` for round-tripping. Three reasons: no per-frame
interpreter on a Pi or a handheld; the output is an ordinary Singe game,
debuggable through the ZeroBrane integration in `zbstudio/`; and it keeps the
ladder continuous -- rules and hand-written Lua are the same material.
### The pressure valve, from the first release
**A "Lua action" and a "Lua behaviour".** One rule needs something the
vocabulary cannot say? Drop to Lua for that rule and keep the project.
Without it every gap is a feature request and the tool grows without bound.
### The editor is a Singe game
* WYSIWYG is literal: same renderer, same video decoder, same fonts, same
overlay coordinates, same light-gun path. Not a preview -- the runtime.
* No second toolchain to build, sign, ship or port. It runs wherever Singe
does, including on the cabinet.
* Play with no export step.
* Precedent: `Tools.singe` is 40 KB of in-engine UI and `MenuDocument.singe`
a full RmlUi document app.
### What the spike settled (2026-09-13)
Chrome in RmlUi, canvas in the overlay. **Not RmlUi as the canvas.**
Proven live, one frame showing all of it: a click on a chrome row consumed by
RmlUi with its Lua handler firing; a click on the canvas falling through to
`onInputPressed`, hit-testing the right rectangle and grabbing it; a click on
empty canvas falling through with nothing under it; motion arriving
throughout.
Guaranteed by the dispatch rather than merely permitted:
* `singe.c:15758` calls `_guiMouseMove()` then `_fireMouseMoved()`
**unconditionally**. Motion is never consumed, so the canvas always knows
the pointer even while it is over the GUI.
* `singe.c:15767` offers buttons to the GUI first; they reach the script only
if nothing used them.
* RmlUi has `pointer-events: none | auto`, inherited, if a document ever must
not block the canvas.
**Re-run under a real window manager (2026-09-13), and the earlier reading was
wrong.** `metacity` is installed, so the spike was repeated under it on an
Xvfb display sized to the overlay, giving an exact 1:1 mapping -- confirmed by
the engine reporting a press at `290,250` for an injected `290,250`. Pointer
motion then arrived properly (17 events) and canvas picking worked perfectly.
But **nothing reached the RmlUi document at all** -- not `dragstart`, and not
`mouseover`, `mousedown` or a row click either, all of which were added to
bisect it. So "RmlUi drag does not fire" was never the finding; in that
configuration the document was not receiving pointer input in the first place,
and the one earlier run where a row click *did* land had a mistaken coordinate
scale. `guiMouseButton` and `guiMouseMove` feed RmlUi correctly
(`gui.cpp:790`, `818`); the routing above them, `_guiPointer`
(`singe.c:3358`), decides which GUI is under the pointer from the rectangles
drawn last frame, and that is where the trail stops. **Why a full-screen
document drawn with `guiDraw` and `guiSetInput(true)` gets no pointer under
`-w` remains open.**
Chased further on 2026-09-13 against the *bundled menu*, on the theory that if
it were real then the menu's documented "click a row to select that game" would
be broken in shipped behaviour. That is **not settled**: no run yet has both
delivered pointer motion and clicked after `menuActive()` became true. One run
delivered motion (`pointer 100,120`) but clicked during the intro, when
`menuRowClick` correctly ignores it; every later run, with and without the
pointer grab released, reported `pointer 0,0` throughout even though ManyMouse
reported `Mice Found: 1` through XInput2. So the difficulty is in delivering
synthetic pointer input to this engine under Xvfb, and **no claim should be
made either way about the menu's mouse** until someone clicks a row on real
hardware. That is a ten-second check for a person and has defeated a long
series of attempts here.
Because of it the event sheet was built **keyboard-first**, which is what the
menu does and what a cabinet needs, and so does not depend on the answer.
**What that changed in the editor.** The panel's drag tab was first an element
in the document with a `mousedown` handler. On this evidence that is a path
the editor cannot rely on, so the tab is drawn on the *canvas* just outside the
panel and picked with `collidePointRect`, and `scene54` presses it through
`authorEditPress` -- the same door a real pointer goes through. Reaching for
the GUI would have looked fine in a test that called the handler directly.
**Three harness facts, each of which cost a run:** a spike must quit on
`singeGetTicks`, not a frame count, because with no vsync under Xvfb the frame
rate is unbounded and it finishes before the driver's first move; the window is
not the overlay (`-C 720x480` gave a 960x640 window, and `shoot.sh` forces `-w`,
which changes input behaviour, so the binary had to be run directly); and
`controls.cfg:56` maps the left mouse button to `INPUT_ACTION_3`, not
`ACTION_1`.
### First slice: two unrelated genres, and not a light gun game
Build the vertical slice with **a 2D platformer and a QTE video scene**. One
genre teaches nothing about generality -- its assumptions end up in the core and
are only discovered on the second. Two unrelated ones force the layer model,
the behaviours and the rule vocabulary to be real. A light gun game afterwards
is nearly free, and the whole thing does not end up shaped by the past.
The keyframe point, worth more than most of the rest: nobody types 2,750
rectangles. Draw a box on frame 100, another on frame 130, interpolate.
`tweenValue` already exists.
### What exists (2026-09-13)
The first two slices: **the format, the vocabulary, the compiler, the runtime,
and a first editor.**
*(Paths below are as first built; everything now lives in `forge/`
and the editor is `Forge.singe`. See the 2026-09-13 evening note at the end.)*
* **`assets/Author.singe`** -- the runtime a compiled game calls, and the
manifest itself. Layers `world2d`, `overlay`, `disc`; looks `box`,
`sprite`, `text`; behaviours `solid`, `platformer`, `drift`; nine
conditions and nine actions, each declaring its parameters and the Lua it
emits.
* **`assets/AuthorCompile.singe`** -- `authorCompile(game)` returns source,
`authorBuild(description, output)` writes it. Written in Lua rather than as
a `util/` script because the editor will be a Singe game and has to compile
what it is editing without leaving the engine.
* **`testScripts/author/platformer.forge`** and **`qte.forge`** -- the two
unrelated genres, sharing nothing but the three nouns.
* **`testScripts/scene52.singe`** and **`scene53.singe`** -- in the regression
set. 52 compiles both descriptions, checks both parse, and plays the
platformer; 53 plays the QTE over the disc. Both verified in both
directions: the platformer reaches the prize (`score=100`) and the QTE
scores 250 when answered and 0 with a `MISS` when it is not.
* **`assets/AuthorEdit.singe`** and **`AuthorEdit.rml`** -- the editor, built to
the spike: entity list and details in RmlUi, canvas in the overlay, picked
with `collidePointRect`. `authorEditSave()` writes the description back,
`authorEditBuild()` compiles what is on screen.
* **`authorLoad`/`authorSave`** in the compiler, and the invariant the editor
rests on: **a description survives the round trip** -- load, save, load, and
it compiles to the same game byte for byte. Getting there found the bug worth
knowing about the format: a condition is a *mixed* table (`{ "keyHeld",
key = "LEFT" }`, an array part plus named keys) and a serialiser that writes
only the array part silently drops every parameter.
* **The event sheet**, in the same file: `Tab` swaps the panel between the
entities and the rules, the selected rule opens in place with its conditions
and actions under it, and `authorEditRuleNew`, `authorEditRuleAdd`,
`authorEditPartSet` and `authorEditPartDelete` edit them. Adding a part
selects it, and a new part gets a starting value per parameter type, so a
rule compiles the moment it is made. The vocabulary comes from `AUTHOR`, so
the rule editor needs no change when a condition or action is added.
**Driven by keys** (`authorEditKey`) rather than the pointer -- see the
window-manager finding above.
* **`testScripts/scene54.singe`** -- opens the editor on a copy of a real
description, picks an entity, drags it, saves, reloads from disc, and checks
the move reached the compiled game.
* **`testScripts/scene55.singe`** -- builds a rule through the editor
(`keyHeld UP` and `onGround hero`, then `jump hero`), checks the compiled
game carries all three, deletes a condition and checks it is gone again.
* All are embedded and extracted like the other support files, and the manual
has a chapter, "Describing a Game Instead of Writing One".
**What running it in three configurations taught, which is worth keeping.**
`scene52` first drove its keys by frame count: 100 under `--deterministic`, 0
under the sanitizer build. Driving on `authorTime()` instead was also wrong,
and failed again as soon as the sanitizer build had a disc to decode --
`physicsUpdate` accumulates wall clock but takes at most `MAX_STEPS_PER_FRAME`
(4, so 66 ms) per frame, so on a machine that cannot keep up the simulation
deliberately falls behind the clock and a clock-driven script outruns it.
**A test of an authored game has to be driven by what the game is doing**:
hold right, jump when `playerIsOnGround` and past a mark, stop when the prize
is taken. That version gives `score=100 after=2.5s` identically under
`--deterministic`, free-running with a disc, and the sanitizer build with a
disc. Any test of an
authored game has to be written on the clock.
**Known limits**, so they are not discovered as surprises: a rule is a flat AND
of its conditions, with "either" expressed as two rules; there are no
expressions beyond what the `lua` action carries; `once` is keyed by a tag the
author picks and never resets; the 2D looks draw with `overlayLine` per row,
which is fine for placeholders and is not a sprite pipeline; the editor had no
undo and no way to add or delete an entity (both added that evening, see below);
a rule's conditions are a flat AND,
so "either" is still two rules; and parameter values are set from script rather
than typed into the panel, which is the next thing the rule editor needs. The panel being under part of the world was the earlier limit
and is fixed: it slides, by a tab drawn on the canvas, so nothing is
permanently out of reach. The event sheet
is the next slice and the one that matters most, because rules are where a
non-programmer actually spends their time.
### Forge as a game (2026-09-13, evening)
Until this pass `Forge.singe` was a library only: it defined the editor and
nothing ran it, so the menu entry opened on a blank screen. Now:
* **A chooser** when nothing is open: the descriptions in Forge's data
directory, any in `Forge/` itself (opened as a copy -- packed, they are read
only), and *New game*, which writes `forgeTemplate()` and opens it.
* **Engine callbacks** -- keys to `forgeKey`, the left mouse button and motion
to `forgePress`/`forgeDrag`/`forgeRelease`, the pad on the chooser.
* **Play** (`P`): save, build beside a copy of the runtime, `scriptPush`. The
engine reruns Forge from the top when the game ends, so the open file is
noted in `last.txt` and reopened on the way back.
* **Entities**: add, duplicate, delete, and every field typed through the same
ENTER form a rule's parameters use -- `forgeFields()` says what the fields of
the selection are and how to reach them, and the entity's come from the
manifest (the look's parameters, then each behaviour's as `type.param`).
Renaming an entity renames it in every rule. Sprite looks draw their image.
* **Pickers** for conditions and actions (`C`, `T`), listing the manifest with
its help line; rules reorder with `[` and `]`; the rule's note is editable.
* **Undo** (`U`): a copy of the description before each change, forty deep.
* `scene57` runs Forge as itself -- no `FORGE_LIBRARY` -- and drives the real
callbacks through the chooser, the entity form, the pickers, undo, close and
reopen, and build.
* Fixed on the way: the compiler loaded `Singe/Author.singe`, which never
shipped (it is `Forge/Author.singe`, and the compiler now loads
`AUTHOR_RUNTIME`); `keyHeld` compared a key name against the scancode the
engine delivers and never fired from a real keyboard; `authorTouching` and
the `solid`/`platformer` behaviours read `look.w` on sprite and text looks
that have none (`authorSize` now answers for every look); an unknown look
type ended the game rather than drawing a box.
Still open, so it is not rediscovered: no expressions beyond the `lua`
action; a rule is a flat AND; `once` never resets; no light gun layer or
keyframed hitboxes yet; the pointer path through the RmlUi document remains
unproven on a real desktop (the canvas path is what the editor relies on); and
the chooser is drawn with `fontPrint`, not the document, because the GUI is
only created once a description is open.
### Out of scope, stated so it is not assumed
A web view. A node-graph editor as the first UI. A separate desktop
application. Importing existing hand-written Singe games. Any tool that
cannot be dropped out of into Lua. And an editor grows without limit unless
this list does too: add to it before adding features.
## 2. Design: Forge for every kind of game (written 2026-09-13)
Section 1 was written for the first slice and built to it: a 2D platformer and
a QTE over video, and then an editor that could move a box and type a rule. It
was right about the shape of the thing and silent about most of the engine. The
owner's ask now is broader and specific: "we especially want to be able to
support 3D gun games like House of the Dead. Plan for every game type you think
you can create." This section re-reads 58 against the engine as it actually is,
says what is wrong or missing, extends the model until the whole catalogue below
fits it, and orders the work.
### What section 1 got right, kept
* **Layers, not genres.** Still the decision everything rests on. The
catalogue below has thirty-odd types of game and every one of them is a
choice of layers plus vocabulary; none needs a mode switch in the core.
* **Three nouns** -- entities, behaviours, rules -- and a manifest the compiler
and the editor read rather than know.
* **Compile to Lua.** A rail shooter on a Pi cannot afford an interpreter in
its frame either.
* **The editor is a Singe game**, and the `lua` action is the way out.
* **Keyboard first**, since the document pointer path is still unproven.
### What section 1 got wrong or left out, counted
The engine registers **502** Lua calls. The manifest wraps **31** of them: three
layers, three looks, three behaviours, nine conditions, nine actions, all of
them 2D. By subsystem, what has no place in a description today:
| Subsystem | Calls | In the manifest |
|---|---|---|
| Nodes, meshes, materials, lights, models, animation, camera, views | 31+9+20+6+4+10+3+3 | none |
| Physics beyond a static box and the character controller: bodies, joints, vehicles, ragdolls, soft bodies, raycast, triggers | 17+5+18+9+8+5 | none |
| Navigation (mesh, agents, crowd, raycast) | 18 | none |
| Particles (2D and 3D emitters, trails, bursts) | 31 | none |
| Sound, music, MIDI | 17+8+17 | none |
| GUI documents, subtitles | 16+5 | none |
| Video players beyond the disc, video on a material | 22 | none |
| Saves, timers, tweens, the score bezel, the master service (boards, scores) | 7+4+3+7+8 | none |
| Disc | 27 | play, seek, frame window |
| Mice, guns, controllers, MIDI in | 7+8+6 | held keys and switches |
And the structural gaps, which matter more than the counts:
1. **Positions are `x, y`.** There is no `z`, so there is no 3D at all.
2. **Every entity is a singleton declared up front.** A shoot-em-up has two
hundred bullets alive; House of the Dead spawns zombies by the dozen and
throws them away. There is no notion of a *type* with *instances*, and no
`spawn`.
3. **Rules only poll.** The engine delivers events -- `onCollision`,
`onTrigger`, `onNavArrived`, `onSoundCompleted`, `onInputPressed`,
`onMouseMoved` -- and the manifest cannot express any of them, so `touching`
is a rectangle test every frame and a key press has no edge.
4. **No state.** An entity has `drive`, `text`, and `visible`. No health, no
ammo, no per-instance variables, and no state machine -- and a state machine
(idle, walk, attack, hit, dead) *is* an enemy.
5. **No time structure.** No timeline, no waves, no camera path, no timers
(`timerAfter` exists and is unused), no levels, no game over.
6. **No expressions and a flat AND.** `once` is keyed by a tag the author picks
and never resets, which breaks the moment something respawns.
7. **No sound, no music, no HUD** beyond a text look, no lives or credits, no
high scores -- the kit and the service (sections 51, 54, 55) are invisible.
8. **The keyframed hitbox** -- the thing section 1 called "worth more than most of the
rest", the answer to the 12,241-line file -- was never built.
9. **Input is two conditions.** No pointer position, no "pressed this frame",
no second gun. A gun game is nothing without the pointer.
10. **The editor** has a 2D canvas, no timeline, no asset list, no 3D view.
None of it argues against the design. All of it says the vocabulary and the
format have to grow along five axes -- depth, instances, events, state, time --
and the editor along two -- a timeline and a 3D viewport.
### The model, extended
Every extension below is backward compatible: a section 1 description is a
valid section 2 one. Nothing is added that a 2D platformer has to write.
**Positions.** `x, y, z`. Absent `z` is zero; 2D layers ignore it.
Rotation `rx, ry, rz` and `scale` likewise, all optional.
**Layers.** Keep `world2d`, `overlay`, `disc`. Add:
* `scene3d` -- `sceneEnable`, gravity, sky, ambient, fog, exposure, shadows.
Parameters are the `sceneSet*` calls, nothing more.
* `hud` -- an RmlUi document (`guiNew`/`guiLoad`) bound to variables: every
`{{score}}`, `{{lives}}`, `{{self.health}}` in the document is refreshed when
the value changes. The score bezel is the same layer with `bezel = true`.
* `music` -- a track and a volume; `musicPlay` on begin.
**Types and instances.** `types = { zombie = { look, behaviours, vars } }`.
An entity in `entities` may name `kind = "zombie"` and override fields; a
`spawn` action or a `spawner` behaviour makes more at run time. The runtime
keeps instances in `AUTHOR_ORDER` as now, with an id per instance (`zombie#7`)
and `authorEach("zombie")` to walk them. A rule may be scoped:
`each = "zombie"` runs its conditions and actions once per live instance with
`self` bound, which is how "every zombie that reaches the camera bites" is one
rule and not one per zombie.
**Per-instance state.** `vars = { health = 3, ammo = 6 }` on a type or an
entity; `self.health` in expressions; `setVar`/`addVar` actions. `state` is a
reserved var: `setState` changes it, `animator` and `sound` behaviours listen
to it, `inState` tests it.
**Looks.** Keep `box`, `sprite`, `text`. Add `sprite` frames and speed;
`model` (`modelLoad`/`modelInstance`, file, scale, starting clip); `billboard`
(a sprite in the scene, `nodeSetSprite` + `nodeSetBillboard`); `mesh`
(box/sphere/plane/cylinder with a material: colour, texture, metallic,
roughness, unlit); `light` (point/spot/sun with colour, range, cone, shadow);
`particles` (an emitter preset); `text3d` (`nodeSetText`); `video` (a mesh
with `materialSetVideo` -- the disc or a file on a surface); `gui` (a document
on a material, `materialSetGui`). A look declares its own `size(entity)` so
`touching` and the bodies stop assuming `w, h` (the `authorSize` fix of
2026-09-13 is the first step).
**Behaviours.** The bundles, each a page of Lua over calls that exist:
| Behaviour | Over | For |
|---|---|---|
| `solid` | `bodyNew` static | walls, floors (exists) |
| `platformer` | player controller, 2D | (exists) |
| `character` | player controller, 3D: move relative to the camera, jump, swim, step, slope | third-person and first-person games |
| `body` | `bodyNew` dynamic: mass, bounce, friction, buoyancy; `bodyApplyImpulse` on demand | crates, balls, debris, projectiles |
| `trigger` | `bodySetTrigger` | checkpoints, doors, kill volumes, weak spots |
| `drift` | (exists) | clouds, platforms |
| `path` | waypoints, speed, loop or once, `tweenValue`; raises `arrived` at each point | moving platforms, patrols, the camera rail |
| `agent` | `navAgentNew`/`navAgentMoveTo`, target entity or point, speed | enemies that walk to the player, crowds |
| `follow` | `nodeLookAt`/lerp toward a target at a distance | companions, homing shots |
| `seek` | straight-line movement toward a target (no nav mesh) | zombies closing on the camera, missiles |
| `vehicle` | `vehicleNew` + wheels, engine, gears, steering | cars, bikes, tanks, boats |
| `thrust` | `bodyApplyForce` along facing, turn rate | ships, planes, hovercraft |
| `health` | `vars.health`, `damage` action, `death` event, optional `ragdoll` on death, `invulnerable` seconds after a hit | anything that can die |
| `shooter` | spawns a `projectile` type from a muzzle offset at a rate, on a switch or a rule | players and turrets |
| `projectile` | speed, life, damage on hit, destroyed on contact | bullets, arrows, fireballs |
| `gun` | the pointer: `mouseGetPosition` (and device index for two guns), 2D hit = `collidePointRect` against types, 3D hit = `sceneUnproject` twice + `physicsRaycast`; raises `hit`/`miss` events with the target; ammo and reload (a button, or a shot off-screen) | light gun games in 2D, over video and in 3D |
| `hitboxes` | keyframed rectangles by disc frame or by time, interpolated | guns over video -- the 12,241-line file becomes a track in the editor |
| `animator` | state to clip: `{ idle = "Idle", walk = "Walk", attack = "Bite", dead = "Die" }`, fades, `animationIsPlaying` polled for `animationDone` | every model with clips |
| `ragdoll` | `ragdollNew`, activated on death, sleeps, and is removed after a time | shooter deaths |
| `spawner` | type, where (a point, along a path, in a trigger, at a waypoint), every N seconds or on demand, max alive, waves | enemies, pickups, traffic |
| `timer` | `timerAfter`/`timerEvery`, raises `timer` events by name | anything timed |
| `sound` | clips by event: `{ hit = "grunt.wav", dead = "fall.wav", spawn = "moan.wav" }` | everything |
| `emit` | an emitter parented to the entity, bursts by event | blood, muzzle flash, sparks, exhaust |
| `camera` | `fixed`, `follow` (offset, lag), `orbit` (mouse look), `first` (on a character's eyes), `rail` (a `path` with look-at targets and stops) | every 3D game |
| `collectible` | destroyed on touch by a named type, adds score or a var | coins, ammo, health packs |
| `lives` | player deaths, continues, credits from the score bezel, game over | arcade games |
| `keys` | maps keys/switches/pad axes to vars (`self.drive`, `self.aim`) so rules read intent rather than hardware | everything with a player |
**Events.** A rule may say `on = "..."` instead of, or as well as, being
polled every frame:
`frame` (the default), `pressed`/`released` (key or switch, an edge),
`collision` (two types, from `onCollision`), `enter`/`leave` (a trigger, from
`onTrigger`), `hit`/`miss` (from `gun`), `death`, `spawn`, `arrived` (a path
point or `onNavArrived`), `animationDone`, `soundDone`, `timer` (by name),
`waveCleared`, `frameReached` (a disc frame), `levelStart`, `levelEnd`.
The compiler emits one dispatcher per engine callback that routes to the rules
by type and id; a description with no event rules compiles to what it does
today.
**Conditions.** Keep the nine. Add `pressed` (edge), `pointerIn(entity)`,
`pointerOffscreen`, `inState`, `var` compare, `distance` less or more,
`inView`, `count(type)` compare, `random(chance)`, `every(seconds)`,
`animationDone`, `atWaypoint`, `waveCleared`, `onScreen`, `lineOfSight`
(`navRaycast`/`physicsRaycast`). `once` gains `per = "instance"` and resets
when the instance is destroyed or the level restarts.
**Actions.** Keep the nine. Add `spawn`, `destroy`, `setVar`, `addVar`,
`setState`, `damage`, `heal`, `playAnimation`, `playSound`, `playMusic`,
`emit` (burst), `shake` (camera), `flash` (overlay), `pathGo`/`pathStop`/
`pathNext`, `cameraCut`, `lookAt`, `push` (impulse), `say` (subtitle or HUD
line for a time), `timerStart`, `gameOver`, `restart`, `nextLevel`,
`loadLevel`, `submitScore` (master service, queued offline), `saveSet`,
`discBranch` (the branching FMV table), `credit`.
**Expressions.** A small, safe syntax where a number goes today: numbers,
strings, `+ - * / %`, comparisons, `and or not`, `self.var`, `score`, `lives`,
`time`, `frame`, `entity("hero").x`, `count("zombie")`, `random(a, b)`,
`distance(a, b)`. Parsed by a hundred-line recursive descent in
AuthorCompile.singe into plain Lua against a whitelist -- no other calls, no
indexing of anything but the names above. The picker offers the names. The
`lua` action stays the door for everything else. This is the item that
decides whether the tool sends people back to code; it goes in the first
milestone, not the last.
**Groups.** `when = { { ... }, any = { { ... }, { ... } } }`: a rule's
conditions are an AND, an `any` inside them is an OR, and they nest. The
editor shows an `any` as an indented block.
**Tracks.** One structure for everything keyed by time or by disc frame:
```
tracks = {
{ name = "camera", key = "time", points = { { at = 0, x, y, z, look = "door" }, { at = 8, ... , stop = "wave1" } } },
{ name = "bandit1", key = "frame", boxes = { { at = 1200, x, y, w, h }, { at = 1230, x, y, w, h } } },
{ name = "waves", key = "time", spawns = { { at = 3, kind = "zombie", count = 3, from = "left" } } },
{ name = "lines", key = "frame", say = { { at = 900, text = "Get down!" } } }
}
```
The camera rail, the light-gun hitboxes, a wave schedule, a cut-scene, and a
subtitle sheet are all tracks; the runtime interpolates points and boxes with
`tweenValue`, and the editor's timeline edits all of them the same way. A
`stop` on a camera point holds the rail until the named wave is cleared (or a
timeout), which is exactly what House of the Dead does at every doorway.
**Levels.** A description may hold `levels = { { title, entities, tracks },
... }` with `types` and `rules` shared; `nextLevel` tears the world down and
builds the next. A single-level description is `levels` of one, implicitly.
Between levels the `hud` layer shows a results card from a template.
**Players.** `players = 2` on the description; `keys` and `gun` behaviours
take a `player` index (mouse device index in `MOUSE_MANY`, controller slot),
score and lives are per player, the bezel's twin score is switched on.
### The catalogue
Every kind of game the engine can carry, with the layers it uses, what it
needs of the vocabulary above, and what is still missing (**E** = engine work,
**V** = vocabulary only, **ED** = editor). A reference title says what is
meant. Where a type is a combination of others it says so; that is the point
of layers.
**A. Over video (the laserdisc lineage)**
1. **Branching FMV** (Dragon's Lair, Space Ace). `disc` + `overlay` or `hud`.
A `frameReached`/`discBetween` window per decision, `pressed` for the move,
`discBranch` to the success or death clip, lives, a move table. V: the
branch table as a track. Missing nothing in the engine; section 47 gave
it every disc control.
2. **Light gun over video** (Mad Dog McCree, Crime Patrol, the ActionMax
titles). `disc` + `overlay`. `gun` + `hitboxes` tracks, `hit` events,
`frameReached` for reload prompts, scoring, civilians as boxes with a
penalty. V + ED: the timeline with frame scrubbing and box drawing is the
whole job; `tweenValue` interpolates between keyframes.
3. **QTE / rhythm over video** (Road Blaster, Sega's Time Gal). Built. Adds
`pressed` edges and a `say` track for prompts.
4. **Video plus sprites** (MACH3, Firefox, Cobra Command). `disc` + `overlay`
+ 2D emitters. Sprite types spawned from a track by frame, `shooter`,
`projectile`, `collision`. V.
5. **Sensor games** (ActionMax). A special case of 2: four hitboxes and a
light sensor; `gun` with `pointerIn` and a frame window. V.
6. **Interactive movie / adventure** (Night Trap, Plumbers Don't Wear Ties).
`disc` + `hud`. Choices as GUI buttons bound to `discBranch`, inventory as
vars, `saveSet` for progress. V (the `hud` layer's bindings).
7. **3D over video** -- House of the Dead's enemies over a filmed background.
`disc` shown on a `video` look filling the view, or the disc under the
scene with a transparent clear, plus everything in 20. E: confirm the
scene composites over the disc with alpha (the overlay does; the scene's
clear colour needs an alpha of zero).
**B. 2D**
8. **Platformer** (built). Gains `health`, `collectible`, `spawner`,
`animator` on sprite frames, levels.
9. **Shoot-em-up / twin-stick** (Xenon, Robotron). `world2d` without gravity
or `overlay` only, `keys` giving `self.aim`, `shooter`/`projectile` types,
`spawner` waves from a track, `collision` events, `health`, `emit`. V.
This is the second genre of milestone 1 because it forces types, spawn and
events.
10. **Run-and-gun** (Contra): 8 + 9.
11. **Arcade physics** (Breakout, Pong, Pinball). `world2d` with `body`
(bounce 1.0), `solid`, `collision` events, `spawner` for bricks from a
grid. V.
12. **Maze / dot-eater** (Pac-Man). `world2d` on a grid: `agent` over a nav
mesh built from the maze (`navBuild` on a plane with holes, or the 2D
grid in `bump.lua` which is already embedded), `collectible`, `inState`
for frightened ghosts. V, plus a `grid` look that lays a tile map (E:
small -- a tile look drawing a sprite sheet per cell).
13. **Puzzle** (Tetris, match-three, sliding). `overlay` + a grid of types,
`keys`, `timer`, expressions over a 2D array var. V, but the array var
and a `grid` helper are needed; the first puzzle will find the gaps.
14. **Point-and-click adventure** (Monkey Island). `overlay` sprites, a
`hud` verb bar, `pointerIn`, `pressed`, `say`, inventory vars, `agent`
walking on a 2D nav mesh, `saveSet`. V.
15. **Visual novel / interactive fiction**. `hud` only: a document with a
text box and choices, vars for flags, `say`, `playMusic`, `saveSet`. V.
16. **Tower defence** (2D). `world2d`, `agent` creeps along a path (a `path`
behaviour suffices), turrets as `shooter` types placed by `pressed` on a
grid, `health`, waves from a track, a `hud`. V.
17. **Top-down driving** (Micro Machines). `world2d`, `body` + `thrust` +
a turn rate, `trigger` checkpoints and a lap counter, an `agent` for AI
cars. V. (Jolt vehicles are 3D only: "a wheeled type in a 2D world"
raises an error.)
18. **Fighting / beat-em-up** (Streets of Rage). `world2d`, `animator` on
sprite frames, `state` machines, and hit/hurt boxes keyed to *animation
frames* -- the same `hitboxes` track keyed by frame of a clip rather than
of the disc. V: the track's key becomes `frame`, `time`, or `clip`.
19. **Rhythm** (Guitar Hero, Rhythm Heaven). `hud` + `music` or MIDI
(`midiPlay`, `onMidiMessage`), a track of notes by time, `pressed` within
a window, `every(seconds)`. V; MIDI input as switches is E (small).
**C. 3D**
20. **Rail shooter** (House of the Dead, Time Crisis, Virtua Cop). Worked
through in the next subsection. The reference for milestone 2.
21. **First-person shooter / arena** (Doom-like). `scene3d`, `character` +
`camera first` + mouse look (`keys` from `onMouseMoved` relative), `gun`
with a raycast from the view centre, `agent` enemies over a nav mesh,
`health`, `spawner`, `collectible`, `hud`. V.
22. **Third-person action / adventure** (Tomb Raider, Zelda). `character` +
`camera follow`/`orbit`, `agent` enemies, `trigger` doors and switches,
`collectible`, `say`, `saveSet`, levels. V.
23. **3D platformer** (Mario 64). 22 with jumping first; the second genre of
milestone 2 because it shares nothing with a rail shooter but the scene.
24. **Racing / driving** (Outrun, Mario Kart). `vehicle` cars on a
heightmap or model track, `trigger` checkpoints, lap and position vars,
`agent` opponents (a `path` with a vehicle following it), `camera follow`,
a `hud` speedometer from `vehicleGetSpeed`. V.
25. **Flying / space / boats** (Star Fox, After Burner, Wave Race). `body` +
`thrust` with pitch and roll from `keys`, `camera follow`, `shooter`,
`spawner`; boats over `bodySetWater`. V.
26. **Sports with a ball** (bowling, golf, billiards, pinball in 3D). `body`
with bounce and friction, `push` from a charged `pressed`, `trigger`
pockets and gutters, `collision` events, `timer` for turns. V.
27. **Physics puzzle / toy** (Angry Birds in 3D, bridge builders). `body`,
`joint` (a `joint` behaviour: hinge, ball, slider between two entities),
`soft` bodies (`softNew`), `push`, `collision` scoring. V: the joint
behaviour is the only new piece.
28. **Tower defence / RTS-lite in 3D**. 16 over a nav mesh with `agent`
crowds and `navRandomPoint`; selection by `sceneUnproject` + raycast. V.
29. **Walking sim / horror** (Amnesia). `character` + `camera first`,
`trigger` scares, `sound` and `emit` on events, `say` notes, `light`
looks that flicker (`addVar` on intensity), `saveSet`. V.
30. **Rhythm in 3D** (Beat Saber-like without VR): 25's types coming down a
`path` on a note track, `gun` or `pointerIn` to hit them. V.
**D. Not games, built the same way**
31. **Attract modes, menus, kiosks**: `hud` + `music` + tracks; the bundled
menu is a hand-written one.
32. **Cut-scenes**: a `camera rail` track, `say` lines, `playAnimation`
actions by time, between levels.
Nothing in the catalogue needs the core to know its genre, which is the test
section 1 set. What it needs is the five axes above, built once.
### The rail shooter, worked
House of the Dead is: a camera on a rail that stops at encounters; enemies
that spawn at each stop, approach, and attack on a timer unless killed; a gun
that hits what the pointer is over; civilians who must not be hit; ammo and a
reload gesture; lives, continues, credits; bosses with weak points; branch
points; a results screen. In the model above:
```
layers = { { kind = "scene3d", sky = "dusk.hdr", fog = { 40, 90 } }, { kind = "hud", document = "hud.rml" } }
players = 2
types = {
zombie = { look = { kind = "model", file = "zombie.glb", scale = 1 },
vars = { health = 3, damage = 1 },
behaviours = { { kind = "health", ragdoll = true },
{ kind = "seek", target = "camera", speed = 1.2, stopAt = 1.5 },
{ kind = "animator", idle = "Idle", walk = "Walk", attack = "Bite", hit = "Flinch", dead = "Die" },
{ kind = "timer", name = "bite", after = 1.4 },
{ kind = "sound", spawn = "moan.wav", hit = "grunt.wav", dead = "fall.wav" } } },
civilian = { look = { kind = "model", file = "hostage.glb" }, vars = { health = 1 },
behaviours = { { kind = "health" }, { kind = "path", speed = 2 } } },
weakspot = { look = { kind = "box3d", w = 0.3, h = 0.3, d = 0.3, visible = false },
behaviours = { { kind = "trigger" } } }
}
entities = { { id = "camera", behaviours = { { kind = "camera", mode = "rail", track = "rail" } } },
{ id = "gun1", behaviours = { { kind = "gun", player = 1, ammo = 6, reload = "offscreen" } } },
{ id = "gun2", behaviours = { { kind = "gun", player = 2, ammo = 6, reload = "offscreen" } } } }
tracks = {
{ name = "rail", key = "time", points = { { at = 0, x = 0, y = 1.6, z = 0, look = { 0, 1.5, -10 } },
{ at = 6, x = 0, y = 1.6, z = -8, look = "door", stop = "hall" },
{ at = 14, x = 4, y = 1.6, z = -20, look = { 4, 1.5, -30 }, stop = "yard" } } },
{ name = "waves", key = "stop", spawns = { { at = "hall", kind = "zombie", count = 3, from = { "doorL", "doorR", "stairs" }, every = 1.5 },
{ at = "hall", kind = "civilian", count = 1, from = "corridor", path = "flee" },
{ at = "yard", kind = "zombie", count = 6, from = "gate", every = 1.0 } } }
}
rules = {
{ note = "A shot lands", on = "hit", each = "zombie", act = { { "damage", entity = "self", amount = 1 }, { "emit", entity = "self", what = "blood" }, { "addScore", amount = 100 } } },
{ note = "Headshot", on = "hit", each = "zombie", when = { { "hitPart", part = "Head" } }, act = { { "damage", entity = "self", amount = 3 }, { "addScore", amount = 400 } } },
{ note = "Reached the camera", on = "arrived", each = "zombie", act = { { "setState", entity = "self", state = "attack" }, { "timerStart", entity = "self", name = "bite" } } },
{ note = "The bite lands", on = "timer", each = "zombie", when = { { "inState", entity = "self", state = "attack" } }, act = { { "damage", entity = "player", amount = "self.damage" }, { "flash", color = "red" }, { "shake", amount = 0.3 } } },
{ note = "Hit a civilian", on = "hit", each = "civilian", act = { { "damage", entity = "player", amount = 1 }, { "say", text = "Don't shoot!" } } },
{ note = "Out of shots", when = { { "var", entity = "gun1", name = "ammo", is = "== 0" } }, act = { { "say", text = "RELOAD" } } },
{ note = "The hall is clear", when = { { "count", kind = "zombie", is = "== 0" }, { "waveDone", name = "hall" } }, act = { { "pathNext", entity = "camera" } } },
{ note = "Dead", on = "death", each = "player", act = { { "lives", change = -1 } } },
{ note = "Level over", on = "levelEnd", act = { { "submitScore", board = "main" }, { "nextLevel" } } }
}
```
How the runtime does each piece, over calls that exist:
* **The rail.** `camera rail` is a `path` on the view's node: position and
look-at interpolated between points with `tweenValue`, `nodeLookAt` each
frame, a `stop` holding until the named wave is cleared (`count == 0` and
the spawner has finished) or a timeout. Pure Lua.
* **The gun.** `mouseGetPosition(device)` in `MOUSE_MANY` (one device per
player; `--manymouse` and the Sinden border already exist), then
`sceneUnproject(sx, sy, 0)` and `sceneUnproject(sx, sy, 100)` give the ray,
`physicsRaycast` gives the node hit. The runtime maps the node to the
instance (`AUTHOR_WORLD` by node) and raises `hit` with `part` = the node's
name, so a ragdoll's `Head` bone or a `weakspot` child is a part. A shot
outside the overlay is `miss` with `offscreen = true`, which `reload =
"offscreen"` consumes. **E, to verify:** that `physicsRaycast` on a model
with a ragdoll reports the bone's node rather than the instance root; if
not, the runtime attaches invisible trigger boxes to named bones instead.
* **Enemies.** `seek` moves the node toward the camera each frame and raises
`arrived` at `stopAt`; `animator` switches clips on `state`
(`animationPlay` with a fade); `health` takes `damage`, sets `hit` then
the previous state after `animationDone`, and on zero sets `dead`, activates
the ragdoll (`ragdollNew`/`ragdollActivate`) and destroys the instance once
`ragdollIsResting` or after a time. `timer` is `timerAfter` by name.
* **Civilians** are the same type machinery with a `path` and a penalty rule.
* **Bosses** are a type with more health, `weakspot` children, an `animator`
with attack clips chosen by rules, and a `hud` bar bound to `self.health`
drawn with `sceneProject` above the node.
* **Blood, flashes, shakes.** `emit` is a 3D emitter parented to the
instance with `emitterBurst`; `flash` is a full-overlay box faded by a
tween; `shake` offsets the view node for a moment.
* **Players, lives, credits.** `lives` wraps the score bezel
(`scoreBezelLives`, `scoreBezelCredits`, `SWITCH_COIN`), `players = 2`
gives two guns and the twin score. `submitScore` queues to the master
service through `Master.singe` and the game id in games.dat.
* **Over video** (7): the same description with a `disc` layer under the
scene; the rail's stops become `frameReached` stops on the disc instead of
time. One key on the track changes and nothing else does.
The test: a scene with a rail of two stops, three zombies, and a civilian,
pointer injected through `authorPointer(x, y)` the way `scene52` injects keys,
asserting kills, the penalty, the rail moving on when the hall is clear, ammo
reaching zero and a reload, and a level end -- identical under
`--deterministic` and free-running, driven by what the game does rather than
by time (the lesson section 1 recorded).
### The editor, for time and depth
Two additions carry most of the catalogue; neither changes the split of
chrome in RmlUi and canvas on the overlay.
* **The timeline.** A panel along the bottom: a ruler in seconds or disc
frames (the disc scrubs with `discSkipToFrame` as the cursor moves, so a
hitbox is drawn on the frame it belongs to), one row per track, keys as
marks, the selected key's fields typed the same way everything else is.
Draw a box on the canvas at the cursor and it is a key on the selected
hitbox track; step frames and draw again; the interpolation is shown
between. This is the light gun editor section 1 promised and the wave editor and
the cut-scene editor at no extra cost.
* **The 3D viewport.** When a description has a `scene3d` layer the canvas
is the scene, drawn by the engine's own renderer with an editor camera
(orbit with the pointer, fly with keys), and the chrome sits over it as
now. Picking is `sceneUnproject` + `physicsRaycast` against editor-only
trigger boxes on every entity; dragging moves along the ground plane
through the click; height and rotation are typed or nudged with keys. No
gizmos in the first version: typed values are exact and the canvas is for
seeing. Play-from-here starts the game with the camera where the editor's
is.
* **Types and assets.** A third panel mode (`Tab` cycles entities, types,
rules): types edited like entities, and the look's `file` field offers the
models, sprites, and sounds found under the game's directory with `lfs.dir`.
* **The picker grows a search**, since the vocabulary will be ten times the
size: type to filter.
The document pointer question from section 1 is still open and still does not block
any of this: every new control is reachable by keys and by the canvas.
### Engine work implied
Small, and all of it useful outside Forge:
* `physicsRaycast` reporting the bone or child node it hit on a ragdoll or a
compound (verify; add if not).
* A `sceneSetBackground` alpha of zero drawing the scene over the disc and the
under-overlay video, for 7 (verify).
* A tile look for grids (12, 13): a sprite sheet drawn per cell from a table,
in the overlay -- or `bump.lua`'s grid, already embedded, with sprites.
* MIDI input as switches for 19 (`onMidiMessage` exists; a mapping in
`controls.cfg` would do).
* Nothing for the rail shooter beyond the raycast check.
### Milestones
Each adds to the manifest, ships a scene, and -- section 1's rule -- proves itself on
two genres that share nothing, so the core stays genre-free.
1. **Types, spawn, events, vars, states, expressions, groups, timers, sound,
HUD.** Genres: a 2D shoot-em-up (9) and a light gun over video (2) with
the hitbox track and the timeline in the editor. This is the milestone
that decides whether the tool is usable at all: expressions and events
are where a non-programmer either stays or leaves.
2. **`scene3d`, `model`/`mesh`/`light`/`billboard` looks, `animator`,
`character`, `camera` modes, `gun` in 3D, `seek`, `health`, `ragdoll`,
the rail.** Genres: the rail shooter (20) and a 3D platformer (23). The
editor gains the viewport.
3. **`agent`, `vehicle`, `thrust`, `trigger`, `joint`, `body`, `push`.**
Genres: racing (24) and tower defence (28). Nav mesh baking from the
level's solids (`navBuild`) as an editor action.
4. **`hud` bindings, `say`, inventory vars, `discBranch`, `saveSet`,
levels, results, `lives`, `submitScore`.** Genres: branching FMV (1)
and a point-and-click adventure (14). Game over, continues, and high
scores arrive here for everything before it.
5. **Editor: types panel, asset browser, picker search, play-from-here,
undo across the timeline.** No new genre; every scene from 1 to 4 is
rebuilt through the editor rather than from a hand-written description.
6. **Sprite frames and `hitboxes` by clip** (18), `grid` (12, 13), MIDI
(19), boats and water (25), soft bodies (27). The long tail, each a
page of manifest and a scene.
The first two milestones are the ones the owner asked for; the rail shooter
is milestone 2 and everything in milestone 1 is on its path (a zombie is a
type, its bite is a timer, its death is an event, its health is a var).
### Corrections to section 1 as written
* `assets/Author.singe`, `AuthorEdit.singe` and `Singe/AuthorCompile.singe`
are `forge/Author.singe`, `Forge.singe` and
`Forge/AuthorCompile.singe`; nothing of Forge is in `Singe/`.
* "The keyframe point, worth more than most of the rest" was not built. It
is milestone 1 here.
* "The 2D looks draw with `overlayLine` per row" stays true for `box`; the
`sprite` look draws its image in the editor now and needs frames.
* `touching` assumed `look.w`; `authorSize` answers for every look now, and
the model above moves size into the look.
* "A rule is a flat AND" is fixed by `any` groups above; "`once` never
resets" by `per = "instance"`.
* The documentation for all of this is `docs/Forge.adoc`, not the manual.
### Out of scope, restated
A web view. A node-graph editor as the first UI. A separate desktop
application. Importing hand-written games. A modeller, a sprite painter or
a sound editor -- assets come from Blender, Aseprite, and Audacity as glTF,
PNG, and WAV. Networked multiplayer. VR. A scripting language of its own
beyond the expressions above: past them is Lua, on purpose. And the list
grows before the features do.
## 3. Revisions after review (written 2026-09-13)
The owner read section 2 and changed two of its premises: **nothing has to be
backward compatible** -- Forge is new and unreleased, so the format and the
vocabulary are designed once and designed right, not grown around the first
slice -- and **the Sierra and LucasArts adventure has to be a first-class
target in both 2D and 3D**. He also asked whether Forge should have its own
planning document; it does now, and this is it.
### Compatibility dropped: what section 2 hedged and this decides
Section 2 kept every section 1 construction valid and added around it. Freed
of that, these are the decisions taken instead; each is one thing where there
were two.
* **Everything is a type.** `types` is the only place a look, behaviours, and
vars are declared; an `entities` entry names its type and gives what
differs (position, id, a var). There is no anonymous entity with its own
look. One path through the compiler and the editor rather than two, and a
thing placed once is a thing that can be spawned later without rewriting it.
* **Positions are always three numbers**, and every look declares its own
size. 2D layers ignore `z` and the 2D editor never shows it. The
runtime's `authorSize` and the `w, h` scattered through section 1 go.
* **Rooms, not levels.** A game is `rooms`; a platformer's levels are rooms
visited once, an adventure's rooms are visited over and over and remember
themselves. Every room keeps its entity state when it is left (positions,
vars, what was taken) unless the room says `reset = true`. Vars declared on
the game (`score`, `flags`, inventory) live across rooms. A `hud`, `music`,
and camera can be set per room and default from the game.
* **Every rule has a trigger.** `on = "frame"` is written, not implied; the
editor's picker for a new rule opens on the event list. Polling every frame
is one choice among twenty, not the default a non-programmer falls into.
* **Every action declares whether it waits.** `say` waits until the line is
done, `walkTo` until the character arrives, `wait` for its seconds,
`playAnimation` optionally until the clip ends, `fade` until the screen is
black. A rule's actions run as a coroutine the runtime resumes each frame,
so a cut-scene, a dialogue, and a door opening are the same rule shape as
`addScore`. This is the piece adventures cannot exist without and the
piece section 2 lacked: it had events and actions, and no notion that an
action takes time. Rules on the same trigger for the same entity are
serialised; a new `on = "pressed"` while a sequence is running is dropped
unless the rule says `interrupt = true`.
* **`once` is per instance and per room** by default and resets with the
room; `once = "game"` is the old behaviour, opted into.
* **Tracks and dialogues are assets of the room**, edited in their own panels,
and referenced by name from behaviours and actions, rather than living
inside the rule that uses them.
* **`hitboxes` is the one keyframe mechanism** for disc guns, animation
hit/hurt boxes, and hotspots that move with a video: a track of shapes
keyed by frame, time, or clip frame. A shape is a rectangle, a circle, or a
polygon (`collidePointPolygon` exists), so a hotspot in an adventure is a
hitbox track with one key.
* **Two players is the general case:** `players = N`, and `keys`, `gun`,
score, and lives are indexed by player from the start rather than added.
* The section 1 samples (`platformer.forge`, `qte.forge`) and scenes 52 to 57
are rewritten to the new format when milestone 1 lands; nothing loads the
old one.
### Adventure games, worked
Two lineages, one model. **Sierra** (King's Quest, Space Quest, Police Quest):
rooms are painted screens with a walk area and exits on the edges, the player
walks with the keys or a click, a text parser (AGI, SCI0) or an icon bar
(SCI1) says what to do, death is frequent and the score counts, and the
sentence "You can't do that here" is a rule. **LucasArts** (Maniac Mansion,
Monkey Island, Day of the Tentacle): a verb bar and a sentence line, a click
picks a hotspot, a dialogue tree with a choice list, no deaths, inventory as
objects with verbs, cut-scenes that take the controls away, and puzzles that
are state (`flags`) plus rules over it. **3D** (Grim Fandango, Escape from
Monkey Island, Gabriel Knight 3): 3D characters on a nav mesh, either over a
painted backdrop from a fixed camera per room or in a modelled room the
camera follows; otherwise the same nouns.
What the model gives them, and what it adds for them:
* **Rooms** (above): a painted `sprite` or `video` backdrop look drawn first,
or a `scene3d` room with a `camera` per room -- `fixed` (position, look-at,
fov, matched to a painting), `follow`, or `orbit`. `exits` are `trigger`
types at the edges or hotspots with a `goTo` action naming the room and the
arrival point; in 2D the runtime scrolls nothing (Sierra rooms are a
screen) unless the room is wider than the overlay, in which case a
`camera follow` in 2D pans it.
* **Walk areas.** A room declares `walk = { polygon... }` (several, with
holes); the runtime triangulates them (ear clipping, sixty lines of Lua),
builds a flat mesh with `meshNew`, and bakes a nav mesh with `navAddNode`
and `navBuild`, so a 2D room's walking is the same `agent` behaviour a 3D
room uses over its floor. Click-to-walk is `navAgentMoveTo` at the click
(2D: the overlay point; 3D: `sceneUnproject` + `physicsRaycast` onto the
floor). `walkTo` in a rule is the same call with `waits`. `onNavArrived`
is the `arrived` event both lineages script against.
* **Depth in a painted room.** `scaleBy = { { y = 300, scale = 0.5 }, { y = 460, scale = 1.0 } }`
on the room scales sprites with their `y` (`spriteScale` exists), and draw
order is by `y` so a character walks behind what is nearer the bottom of
the picture. `behind` looks -- a cut-out of the painting drawn after the
characters -- give a pillar to walk behind. In 3D over a painting the same
needs **occluder** meshes that write depth and no colour: **E**, a
material flag (`materialSetOccluder`), small, and nothing else in the 3D
lineage is missing from the engine.
* **Hotspots.** A `hotspot` type: a `hitboxes` shape (rectangle, circle,
polygon) with no picture, a `name` for the sentence line ("look at
*window*"), a `walkTo` point, and vars. `pointerIn` and `pressed` find it;
`on = "verb"` rules (below) act on it. A hotspot may move with a video
(a keyed track) or with an entity (parented).
* **Verbs and the sentence line.** The game declares `verbs = { "look",
"take", "use", "talk", "open", ... }` and the `hud` document shows them as
buttons (LucasArts) or an icon bar (SCI1); the runtime builds the sentence
from verb, hotspot, and, for `use`, a second hotspot or an inventory item,
and raises `on = "verb", verb = "use", item = "key", target = "door"`. A
rule matches on any subset (`verb` alone is "use anything on anything");
the most specific rule wins and the room's default rule ("That doesn't
work") catches the rest. Right-click cycles verbs, the way the later
LucasArts games do, as a `hud` option.
* **The parser.** A `parser` layer for the Sierra lineage: `words = {
look = { "look", "examine", "l" }, door = { "door", "gate" } }` and
`on = "said", verb = "look", noun = "door"` rules; unknown words answer
from a table; `keyboardSetMode` text entry with a prompt line in the `hud`.
Both input styles raise the same `verb` event, so a room's rules do not
care which the game uses, and a game can offer both.
* **Inventory.** An `inventory` var on the player (a list of item types),
`give`/`take` actions, the `hud` bound to it as a scrolling list, items
usable as the `item` of a `verb` event, and `has(item)` in expressions.
* **Dialogue.** A `dialogues` asset per room or game: nodes of `say` lines
(who, text, optional clip, and portrait) and `choices`, each with `when`
conditions, `act` actions, `goto`, and `once`; a `talk` action starts one
and waits until it ends; the `hud` shows the choices; lines are `say`
actions with a duration from their length (or a voice file's) and a
`srt`-style subtitle placement over the speaker via `sceneProject` in 3D.
A dialogue is a tree in the editor's panel, edited as an outline.
* **Flags and puzzles.** Vars at game scope; `setVar`/`addVar`; `when` on
them; `once`. A puzzle is state and rules, which the model already is.
* **Cut-scenes.** A rule with `waits` actions and `controls = false` for its
duration: `walkTo`, `say`, `playAnimation`, `fade`, `cameraCut`, `wait`,
`goTo`. Tracks for anything timed to the frame.
* **Death and scoring** (Sierra): a `die` action with a message and
restore/restart/quit choices from the `hud`; `addScore` with `max` shown
as "12 of 300"; `saveGame`/`loadGame` actions capture the whole state --
room, positions, vars, inventory, dialogue `once` flags -- through
`saveSetAll`, and a `hud` restore list.
* **3D specifics.** Characters are `model` types with `animator` (idle,
walk, talk, pick up) and `agent` on the room's nav mesh (baked from the
room's floor solids with `navBuild`); `camera fixed` per room with the
painting on a `video` or `sprite` backdrop look filling the view, or
`camera follow` in a modelled room; hotspots are `trigger` boxes or
polygons on the floor; `lookAt` turns the head or the body toward a
hotspot before a `say`; tank controls are `keys` mapping forward/turn onto
`character`; point-and-click is the same `navAgentMoveTo`. Occluders as
above.
What the editor adds for them: polygon drawing on the canvas (walk areas,
hotspots, `behind` cut-outs), an outline panel for dialogues, the verb list
and parser words as tables typed like everything else, per-room camera set
from the viewport ("use this view"), and play-from-this-room with the
inventory and flags set as the editor has them.
The test, in the spirit of section 1's two-genres rule: one description, two
rooms, one hotspot each, a door that needs a key taken in the other room, a
dialogue with a choice that sets the flag the door checks, a cut-scene on
opening it, and a score of two -- built once over a painted backdrop in 2D and
once in a modelled 3D room with a fixed camera, the rules identical between
the two. Driven by `authorPointer` and `authorSay("use key on door")` rather
than by time.
### Where the adventure sits in the milestones
Section 2's milestone 4 was "HUD bindings, `say`, inventory, `discBranch`,
saves, levels, results, lives, high scores" proved on branching FMV and a
point-and-click adventure. With the owner's emphasis the order changes:
1. Types, events, vars, states, expressions, groups, timers, sound, HUD --
and **sequences** (`waits`), since they are the substance of milestone 3
and cheap to do here. Genres: the shoot-em-up and the light gun over
video, as before.
2. `scene3d`, models, `animator`, `character`, `camera` modes, the 3D gun,
`seek`, `health`, `ragdoll`, the rail. Genres: the rail shooter and the
3D platformer.
3. **The adventure**: rooms, walk areas and 2D nav, hotspots, verbs and the
sentence line, the parser, inventory, dialogues, cut-scenes, saves,
scaling, occluders (the one engine change). Genres: the same adventure in
2D and in 3D, as above.
4. `agent` crowds, `vehicle`, `thrust`, `joint`, `body`, `push`: racing and
tower defence.
5. Branching FMV, lives, continues, `submitScore`, results: the arcade
plumbing every earlier genre then inherits.
6. Editor: types panel, asset browser, picker search, timeline polish,
dialogue outline, polygon tools, play-from-here.
7. The long tail: sprite frames and clip hitboxes, grids, MIDI, boats and
water, soft bodies.
### Out of scope, carried forward
As section 2 states it, plus: a text parser that understands grammar beyond
verb, noun, and preposition (Sierra's did not either); lip sync; a
dialogue *writing* tool beyond the outline (a writer's tool is a different
program); and localisation, which is a table of strings and a milestone of
its own once there is something to translate.
## 4. Milestone 1, built (2026-09-14)
Types, rooms, events, vars and states, expressions, groups, timers, sound, a
HUD, sequences, the hitbox track and the timeline. Proved on the shoot-em-up
(`testScripts/author/shmup.forge`, scene 58) and the light gun over video
(`gunvideo.forge`, scene 59); the first slice's platformer and QTE were
rewritten to the new format and still pass (scenes 52 and 53), and the editor
scenes (54 to 57) were adapted, with scene 60 for the timeline.
**What was built, against section 3's decisions:**
* `Author.singe` rewritten: `authorBegin(description)` builds the layers,
the vars, and the first room; `authorRules(list)` takes the compiled rules
by event; `authorFrame` runs timers, behaviours, sequences, the frame rules,
the type-pair collision test, the HUD bindings, and the draw. Instances are
made from types (`authorSpawn`, `authorDestroy`), rooms keep their instances
when left (`authorGoTo`), and every instance has `vars` with `state`.
* Events: `frame`, `roomStart`, `roomEnd`, `pressed`, `released`,
`collision` (type pairs, an edge), `hit`, `miss`, `death`, `spawn`, `timer`,
`frameReached`, `gameOver`. A rule names one with `on` and filters with the
event's own parameters; `each` scopes it to a type with `self` bound.
* Sequences: an action with `waits` (`say`, `wait`) yields from the rule's
coroutine through `authorWait`; a rule without one runs plainly.
`controls = false` takes the keys away for its duration; the same rule on
the same instance does not restart unless `interrupt`.
* Expressions: `AuthorCompile.singe` carries a recursive descent parser
(`authorExpression`) with a whitelist -- `self.var`, `other.var`,
`event.field`, `name.field`, game vars by name, `count`, `random`,
`distance`, `has`, `abs`, `min`, `max`, `floor`, `time`, `frame`. Every
`number` or `expression` parameter accepts one. `any` groups nest.
* The manifest's `emit` functions receive Lua fragments, not values: the
compiler resolves each parameter by its declared type. Behaviour
parameters, which travel as data, resolve key and switch names at run time.
* `authorCheck(description)` names undeclared types, looks, behaviours,
conditions, actions, and events, and expressions that do not parse.
* The editor: four panels (`Tab` cycles entities, types, rules, tracks),
rooms (`PgUp`/`PgDn`, `N` adds one), types typed field by field with looks
and behaviours from the manifest and renames that follow through the rules,
entities as type plus overrides, rules with `E` picking the event and its
filters typed, `O` for an `any` condition, and the timeline: the disc
layer's video scrubbed under the cursor, keys made at it (`K`), dragged on
the canvas, typed, and deleted.
* `docs/ForgeVocabulary.adoc` is generated from the manifest by
`util/forgeVocabulary.lua` (in `build-docs.sh` and the CMake rule), so the
manual's tables cannot drift.
**Decisions taken while building, for the record:**
* A behaviour's parameter cannot be called `type` -- the key names the
behaviour -- so `shooter` and `spawner` say `spawns`.
* A gun hits only instances with a `target` or `hitbox` behaviour. The first
version hit the score readout.
* File names in a description are relative to the game's directory, like
every other Singe path.
* A description's `size = { w, h }` sets the overlay; without it the
engine's default stands, which over a disc is the disc's own.
* The spawner's count is a var (`self.made`), since rules read vars and not
runtime fields.
**Not yet, from the milestone's list:** sprite frame animation as an
`animator` (the `sprite` look takes `frames` and the var `frame`, but nothing
steps it yet); the `spawns` track type (waves are a `spawner` behaviour for
now); `chance`/`every` are in, `soundDone` is not. All are milestone 6 or
arrive with the genres that need them.
## 5. Milestone 2, built (2026-09-14)
The scene: `scene3d`, the `model`, `mesh`, `light`, `billboard`, and `text3d`
looks, `character`, `camera` (fixed, follow, orbit, first, rail), `seek`,
`animator`, `target` with zones, `health` with ragdolls, `body`, `trigger`,
the gun's ray, and the rail. Proved on the rail shooter
(`testScripts/author/railshooter.forge`, scene 61: two stops, seven zombies
killed, seven headshots, the rail moving on when each stop is clear, the end
reached) and the 3D platformer (`platformer3d.forge`, scene 62: the
controller moving relative to a following camera, a jump onto a ledge, a
prize that is a trigger). The editor shows a 3D room as the scene under an
orbiting camera, picks and drags by projection, and puts rail points where
the camera stands (scene 63).
**How the pieces went, against section 2's design:**
* Every node an instance owns -- its own, a model's whole subtree, the body
box, the zone boxes -- is registered in `AUTHOR_BY_NODE` when it is made, so
a ray or a contact maps to its instance with a table lookup. The first
version walked `nodeGetParent`, and physics reported a node a rule had just
deleted (a prize destroyed as it was entered); asking the engine about a
dead node ends the game, so the walk went.
* `target` in 3D is a kinematic trigger box on a child node raised by half
the look's height, since a model stands on its origin. Zones are boxes
parented to named bones (`b_Head_05` on the Fox). The ray meets the body
box first, so zones are found by how close the ray passes to each zone's
centre -- no second cast, and no engine change: the raycast-hits-bone
question from section 2 is answered without it.
* The rail is `authorRailStep`: points by time, position and look-at
interpolated, a `stop` held until `pathNext`, `stopped` and `railEnd`
events. `shake` offsets the rail camera for a moment.
* `character` moves relative to the camera's ground axes
(`authorCameraAxes`), which come from a `first`/`orbit` camera's yaw or a
`follow` camera's bearing to its target.
* Ragdolls: `health` with `ragdoll = true` makes the ragdoll at attach and
activates it on death, keeps the instance for `linger` seconds through a
named timer, and takes it off the targets.
* The editor's preview reuses the runtime's look `load` functions on a fake
instance, rebuilt when what the entities *are* changes (a stamp of types
and looks) and moved every frame; the camera orbits the selection; drags
cross the plane of the entity's own height.
**Not yet:** `first` and `orbit` cameras are in the manifest and untested
(the FPS is milestone 4's territory); `billboard` and `text3d` likewise;
`animationDone` is polled from `animationIsPlaying`; the timeline's
`spawns` track is still a `spawner` behaviour and a rule per stop.
## 6. Milestone 3, built (2026-09-14): the adventure
Rooms that remember themselves, walk areas baked to a navigation mesh (2D
polygons or 3D floors), the `walker`, hotspots with polygons, verbs and the
sentence line with most-specific-wins, the parser and `said`, the inventory,
dialogue trees compiled with their conditions and actions, cut-scenes across
rooms, fades, depth sorting and scaling, feet anchors, `saveGame`/`loadGame`,
and `die`. Proved on `adventure2d.forge` (scene 64) and `adventure3d.forge`
(scene 65), which takes the 2D game's rules, dialogue, verbs, and parser
unchanged and supplies only types and rooms -- the test section 3 asked for.
The editor types a room's own fields and draws its walk areas.
**How the pieces went:**
* Walking in 2D: the polygons are ear-clipped in Lua into a flat mesh
scaled by a hundredth (`NAV_SCALE`), baked with `navNew`/`navAddNode`/
`navBuild`, and an agent drives a separate navigation node whose x and z
are copied back to the instance's overlay x and y every frame. In 3D the
agent drives the instance's node. **Engine fix:** `navAgentNew` read the
node's world position as of the last drawn frame, so an agent made in a
game that never draws the scene sat at the origin, off the mesh; it
refreshes the transforms first now, as `navAddNode` already did.
* Exclusive events: rules are tiered by the parameters they name (two each)
plus one for having conditions, and the highest tier whose conditions hold
acts; a rule's compiled function answers whether its conditions held, and
a sequence that yielded has passed them. This is what lets "I do not know
the word" (guarded, no parameters) beat "You cannot do that here" and fall
through to it when there is no unknown word.
* Sequences survive a room change: the cut-scene that walks through the
door is the one changing rooms and has a fade-in still to do. They are
cleared only by a restart or a load.
* `goTo` with `at` names an entity in the destination room to arrive at,
which is how one rule serves both worlds.
* The dialogue's `next` was first `goto`, which Lua reserves.
* Physics contacts now reach `collision` rules, so the rules' `a` and `b`
types are matched (the first version matched neither, and a platformer's
hero destroyed its own ground).
**Not yet:** occluders for a 3D character walking behind a painted pillar
(the `materialSetOccluder` engine change section 3 named); the editor's
polygon drawing and dialogue outline (milestone 6); a sprite `animator` for
walk cycles (`state` and `facing` are set, nothing steps frames yet).
## 7. Milestone 4, built (2026-09-14): vehicles, crowds, turrets, joints
`vehicle` (car, motorcycle, tank, boat over `vehicleNew`, wheels hung from
the look's corners, driven by the `keys` vars, `self.speed`), `racer` (a
vehicle steering itself round a track of points from its yaw), `walker`
`follow` (repathing after a target), `patrol` with `patrolEnd`, `turret`
(nearest target in range, a projectile aimed by velocity overrides at spawn),
`spawnAtPointer`, `thrust`, and `joint` (hinge, ball, slider, to another body
or the world, made on the first frame so the other body exists). Proved on
`racing.forge` (scene 66: the car reaches 22 units a second, passes two
checkpoint triggers for a lap, the rival drives its track, the hovercraft
moves under thrust, the hinged bar hangs) and `towerdefence.forge` (scene 67:
ten creeps in waves along a patrol, two turrets placed by clicks projected
onto the floor, ten kills, gold and lives kept by rules). Both worked on the
first run, which says the model from the earlier milestones held.
**Decided on the way:** the vehicle's forward is -Z, as the engine's is, so
the throttle is `-dy`; the racer's steering is the signed angle between the
chassis' heading (from `nodeGetRotation`) and the next point, clamped; a
turret without a projectile type does its damage directly.
## 8. Milestone 5, built (2026-09-14): the arcade plumbing
The `branches` track and the `branching` behaviour (`branchOpen`,
`branchTaken`, `branchMissed`; the disc sent to the success or the fail
frame), the `bezel` layer mirroring `score`, `lives`, `credits` (and a second
player's) to the arcade panel, `credit`, `gameOver` with the results card and
the best score kept in a save, and `submitScore` loading `Singe/Net.singe`
and `Singe/Master.singe` on first use and pumping them after. Proved on
`fmv.forge` (scene 68): a window answered in time, three missed, the lives
spent, the score sent once (the master client stubbed by the scene), a coin,
and a continue that starts over with three lives. The point-and-click
adventure this milestone was to share with had already landed in milestone 3.
## 9. Milestone 6, built (2026-09-14): the editor
Typing narrows every picker; a field that names a file (by the manifest's
type) opens a picker of the files under the game's directory, two levels
deep, in place of the prompt; `V` draws a walk area or a hotspot's outline
corner by corner on the 2D canvas; the dialogues are a fifth panel, an
outline of dialogues, nodes, choices, and a choice's actions, with `A`
adding at the level selected and `T` picking an action; and `P` plays from
the room on screen by building a copy with that room first. The types panel,
the asset list, and undo across the timeline had landed with the earlier
milestones. Scene 69 drives all of it and checks what reached the
description and the compiled game.
The one thing the milestone planned and this does not do is rebuild every
earlier scene through the editor rather than from a hand-written description;
scenes 54, 55, 57, 60, 63, and 69 between them exercise every editing path
those descriptions use, so the rebuild was not worth its cost in scenes.
## 10. Milestone 7, built (2026-09-14): the long tail
The `frames` behaviour stepping a sprite sheet by state, the `grid` look
drawing a tile map from a sheet with `spriteDrawGrid`, the `midi` event from
`onMidiMessage` (its filter is `pitch`, because `note` is what a rule's
comment is called -- the first version tried to compile every rule's comment
as a MIDI note), the `water` behaviour over `bodySetWater`, `buoyancy` on
`body`, boats with `thrust` through the `vehicle` behaviour, and `soft` over
`softNew`. Proved on `tail.forge` (scene 70: five frames of a walk cycle
seen, a 5 by 3 grid measuring 160 by 96, middle C scoring and a second note
counted) and `pool.forge` (scene 71: a ball that floats at the surface, a
boat that travels nineteen units under its propeller, a balloon that
inflates).
With this every kind of game in section 2's catalogue has its vocabulary in
the manifest, and every milestone of section 3 is built. What remains is
listed where it was found: occluders for 3D over a painting (section 6),
first-person and orbit cameras untested (section 5), the editor rebuilding
every scene (section 9), and the light gun cabinet checks that need a person
at a real machine (section 1).
*Closed 2026-09-14:* occluders came with section 12; first and orbit
cameras are driven by injected mouse motion and asserted on in scenes 72
(fps.forge) and 75 (thirdperson.forge), so "untested" was stale; `billboard`
and `text3d` looks now stand in those samples (puffs at the rail shooter's
stops, a sign in the arena) and are checked; the `spawns` track is built
(a waves track: `authorWavesAtStop`, `updateWaves`, `W` and `K` in the
editor, scenes 94 and 95, sample waves.forge); and `soundDone` arrived
(`authorPlaySound` remembers the channel's file and owner, the compiled
game binds `onSoundCompleted` to `authorSoundDone`). Only the cabinet
checks remain.
## 11. The rest of the catalogue (2026-09-14)
With the milestones built, the remaining types of game in section 2's
catalogue were written as descriptions to find what the vocabulary still
lacked. Each is a sample in `testScripts/author/` with a scene:
* **First-person shooter** (`fps.forge`, scene 72): `camera first` on the
hero's eyes turned by the mouse, `keys` moving relative to it, a `gun` with
`aim = "centre"`, zombies on `walker follow`. Found: the gun's ray met the
hero's own body first; it now passes through anything that is not a target
(a few casts on from each hit) before counting a miss.
* **Breakout** (`breakout.forge`, scene 73): `solid` with `moving` (a
kinematic paddle that follows the `mover`), a `body` ball with bounce 1,
`push` on `roomStart`. Found: events raised while the first room is built
-- `roomStart`, every placed entity's `spawn` -- came before the compiled
game handed over its rules, and were lost; they are kept and replayed by
`authorRules` now.
* **Maze** (`maze.forge`, scene 74): a `grid` look for the walls, corridors
as walk areas, a ghost on `walker follow`, dots eaten by collision.
* **Third person** (`thirdperson.forge`, scene 75): `camera orbit` under the
mouse, `keys` relative to it, a sword swing as `on = "pressed"` with `each =
"enemy"` and a distance test. Found: `each` on an event with no instance
of its own (a key, the disc, a room) now fans the rule out over every
instance of the type, as a frame rule does; `distance` takes ids as well as
instances and measures through the scene in 3D.
* **Space shooter** (`space.forge`, scene 76): `thrust` with no gravity, a
`shooter` firing 3D projectiles, rocks as slow projectiles with `health`.
* **Rhythm** (`rhythm.forge`, scene 77): a `music` layer, notes as instances
falling to a marker, a press scored by where the note is.
* **Bowling** (`bowling.forge`, scene 78): a heavy `body` ball shoved by
`push`, pins as bodies counted by how far they lean, a `trigger` at the end
of the lane.
## 12. Occluders and gizmos (2026-09-14)
**Occluders**, the engine change section 3 named: a `PIPELINE_OCCLUDER`
variant in `scene.c` whose colour target has its write mask cleared, so the
mesh goes into the depth buffer and nowhere else; `materialSetOccluder` sets
it, an occluder casts no shadow (the painting already has one), and the GLES
shim already honoured the mask. Forge's `mesh` look takes `occluder = true`;
the editor's preview marks its instances so they stay visible for placing.
`painted.forge` (scene 79) is a picture on a flat mesh, an occluder pillar, and
the Fox crossing behind it -- and found that `materialSetTexture` takes a
KTX2 file or a sprite handle, so the look loads any other picture as a sprite
first.
**Gizmos** in the 3D viewport: three arms from the selected entity along the
world axes, projected with `sceneProject`, each with a handle; a press on a
handle starts a drag along that axis, the pointer's travel along the arm as a
fraction of the arm being the distance moved. Scene 80 raises a wall by an
arm's length on Y, brings it half an arm back on X, and undoes both.
**Rotation and facing** (the two "minor" items): entities carry `rx`,
`ry`, `rz` (applied to the instance's node by `make`, shown in the 3D preview,
typed in the entities panel), and the gizmo has a fourth arm, yellow, the way
the entity faces, whose handle dragged round the entity turns it about Y
(scene 80, a quarter turn; scene 79 checks the rotation reaches the node).
Sprite facing was data at first -- a `frames` range named `walkLeft` used
while `facing` is left -- and is now an engine flip as well: `spriteFlip`
mirrors a sprite for every draw, so a `sprite` look mirrors itself while
the instance faces away from its art (`faces`, right unless said), and a
`Left` or `Right` range is only for sheets drawn both ways; scene 70 holds
LEFT and sees only the drawn frames, scene 81 sees the mirror. A per-entity
`scale` (typed; no handle) scales the node -- look, body, and counted size
-- with bodies given the look's unscaled size, since Jolt takes the node's
scale itself (scene 79).
**Sprite rotation and the rest of the sweep** (2026-09-14): a `sprite` turns
with the var `angle` (seeded from the entity's `rz` in a 2D room; a turning
instance gets an image of its own so the shared one is not rebuilt for every
angle in the room), a `spin` behaviour turns the angle or the node about Y,
a `projectile` with `aim` points its sprite along its flight, a `particles`
look is a started emitter, `stopMusic` stops the track, and `authorCheck`
names any field a look, a behaviour, or an event does not take. Found on
the way: a 2D emitter draws nothing until `emitterDraw` is called each frame
-- the shoot-em-up's explosions had never appeared -- so the frame draws every
live instance's emitter in a 2D room. Scene 70 covers the spinner, the
fire, and three deliberate misspellings.
**Mirrors, turned sheets, clip volumes, the 2D turn handle, and thumbnails**
(2026-09-14): the engine gained `spriteFlip` (a mirror applied to every draw
of a sprite, frames included), and a `sprite` look mirrors itself while the
instance faces away from its art (`faces`); a sheet entity with an angle
draws its frame turned through `spriteRotateFrame`; `soundPlay` takes a
volume and `soundSetChannelVolume` changes a channel's own, under the master
(which used to be applied through the mixer's tag gain, overwriting the
distance fade of positioned channels -- it is per track now), so the
`playSound` action and the `sound` behaviour take a `volume` of 0 to 100;
the 2D canvas has the yellow turn handle the scene had, dragged round the
entity to set `rz`, and a sheet entity on the canvas shows its first frame
turned; and the types panel shows each type's picture -- the sprite through
RmlUi's `<img>`, which reads the vfs, or a swatch of the box's colour --
beside its name. Scenes 81 (runtime) and 82 (editor). Still not doable
here: the hardware checks (RmlUi's document pointer path on a real desktop,
light gun coordinates, off-screen reload, and two guns).
## 13. Tutorials (planned 2026-09-14)
The reference chapters say what Forge is; nothing yet says what to *do*
first. The tutorials go at the front of `docs/Forge.adoc`, as a part of
their own (`== Tutorials`) between *About Forge* and *Describing a Game
Instead of Writing One*, so the book opens with a game being made and only
then explains the pieces. They stay out of the Singe manual, like all Forge
documentation.
### 13.1 Shape
Every tutorial is one sitting and ends with a game that plays. Each has the
same skeleton, so the tenth reads like the first:
1. **What you will make** -- one sentence and one picture of the finished
game.
2. **What you need** -- the tutorial before it (or nothing), and which files
of the kit (13.3) it uses.
3. **Steps** -- numbered, each one key or one form: *press `A` -- a new
type appears at the top of the list*, with a picture wherever the screen
changes in a way words would fumble. Never two ideas in one step.
4. **What happened** -- the concept the steps just used (an event, a
behaviour, a track), in a paragraph, with a link to the reference chapter
that owns it. This is where the tutorial teaches; the steps only do.
5. **Try this** -- two or three variations that need no new steps (change
the jump, add a second coin, move the door), so the reader leaves the rails
on purpose.
6. **The description** -- the finished `.forge` text, in full for the short
ones and as a diff against the previous tutorial for the long ones, so a
reader who lost their way can compare, and a reader who prefers a text
editor can skip the steps entirely.
Pictures are block images (`image::tutorials/01-chooser.png[]`) under
`docs/images/`, PNG, embedded in Forge.pdf by asciidoctor-pdf. A full 720
by 480 frame of the editor reads well at page width; cropping is a later
nicety, not a requirement.
### 13.2 The tutorials are tests
The pictures are not taken by hand. Each tutorial has a scene
(`testScripts/scene83.singe` upward, one per tutorial) that drives the editor
through exactly the keys the text names -- `forgeKey`, `forgePress`,
`forgeDrag`, the typed forms -- shooting a frame at each pictured step, and
at the end asserts two things: that the description on screen equals the
tutorial's shipped `.forge` (the round trip scene 54 already relies on), and
that the built game runs a few seconds without a `FAIL`. A change to the
editor that breaks a tutorial breaks its scene; a change that moves a panel
regenerates the pictures. The refs sweep runs them like every other scene.
Two small engine and editor pieces make that clean:
- `singeScreenshot(name)` -- an optional file name, so a scene writes
`01-chooser.png` rather than `singe007.png` for a rename step to guess at.
Manual entry and CHANGELOG line.
- a harness step (not in the repo, like the rest of the harness) that copies
a tutorial scene's shots into `docs/images/tutorials/`. The images *are*
in the repo, since the docs build reads them; the screenshots folder stays
scratch.
### 13.3 The kit
A tutorial cannot say "find a picture of a hero": the pictures must be
there. `forge/kit/` ships in Forge.game and the first run copies it
out beside Forge.pdf, where *New game* already writes descriptions, so every
tutorial's file names are the same on every machine (`kit/hero.png`). What
is in it, all ASCII-named, all small, all ours or CC0:
- `hero.png` -- a sprite sheet, 8 columns: idle, a 4-frame walk facing
right, a jump, a fall, a hit. Drawn once, mirrored by `faces`.
- `coin.png`, `crate.png`, `door.png`, `spike.png` -- stills.
- `tiles.png` -- a 4-column tile sheet for `grid` looks.
- `enemy.png` -- a 4-column sheet for the shoot-em-up and the gun game.
- `room.png`, `room.jpg` -- a painted adventure room, with a floor to walk
and a pillar to walk behind (the 3D adventure reuses it under occluders).
- `fox.glb` -- the Fox model (already used by seven samples; its licence
goes in `kit/LICENSE`), `crate.glb` a box with a texture.
- `jump.wav`, `coin.wav`, `shot.wav`, `hit.wav`, `click.wav`, and a short
`loop.ogg` for a music layer.
- `clip.mkv` -- twenty seconds of video with three obvious moments in it
(a door opens, a target rises, a hand reaches), for the FMV, QTE, and gun
tutorials. `Singe/menuBackground.mkv` will do until it is made.
Two consequences for the editor, both gaps today:
- `forgeExport` copies the runtime and nothing else; it must copy every
file the description names (looks, sounds, music, video, models, `hud`)
into the export folder, rewriting the names to be relative to it, or a
packed game refers to a kit that is not there.
- the file picker lists the game's directory; it should list `kit/` under
it too, which it will once the kit is extracted beside the descriptions.
### 13.4 The tutorials
Ten, in an order where each needs only what came before. The sample each
grows into already exists and is named; the tutorial's own shipped
description is a smaller, cleaner cousin of it (`Forge/tutorials/NN-name.forge`,
packed and listed by the chooser under a *Tutorials* heading).
| # | Title | Makes | Teaches | Grows into | Scene |
|---|-------|-------|---------|------------|-------|
| 1 | **Ten minutes** | The *New game* starter, played and changed | the chooser, the four panels, `Enter` forms, `P`, `S`, `Esc`, where Forge.pdf and the kit are | -- | 83 |
| 2 | **A platformer** | coins to collect, spikes that kill, a door to a second room | types and instances, the `collision` event, `each`, `addScore`, `destroy`, rooms and `nextRoom`, the readout | platformer.forge | 84 |
| 3 | **Sprites and sound** | the same game with the kit's hero sheet, mirrored facing, a jump sound, music | `sprite` with `frames` and `faces`, the `frames` behaviour and states, `setState` rules, the `sound` behaviour with `volume`, the `music` layer, `rz` and the turn handle | platformer.forge | 85 |
| 4 | **A shoot-em-up** | waves from a spawner, a ship that fires, explosions, lives, game over | `world2d` without gravity, `spawner`, `projectile` with `aim`, `hp`, `emit` and `particles`, `lives`, `gameOver`, the results screen | shmup.forge | 86 |
| 5 | **A quick-time event** | a clip with three prompts and a penalty for missing one | the `fmv` layer, tracks, the timeline (`,` `.` `[` `]` `K`), time windows, `prompt`, `disc` conditions, branching to a second clip | qte.forge, fmv.forge | 87 |
| 6 | **A light gun game** | targets that rise from the clip, ammo, reload off screen, a score | the `gun` behaviour, target boxes on the timeline, `hit` and `miss`, `pointerOffscreen`, two players, what a cabinet needs (the light gun section) | gunvideo.forge | 88 |
| 7 | **A point-and-click adventure** | two painted rooms, a walker, a locked door and a key, a conversation | walk areas (`V`), `depthSort` and `scaleBy`, hotspots and verbs, inventory, `said`, dialogues (the fifth panel), `fade`, `save` | adventure2d.forge | 89 |
| 8 | **Into 3D** | the platformer again, in a scene: a fox on boxes with a camera behind it | `scene3d`, `model` and `mesh` looks, `light`, the 3D viewport keys, gizmos, `character`, `camera` follow, `ry` | platformer3d.forge | 90 |
| 9 | **A rail shooter** | the camera rides a rail through a painted room; targets rise at stops and fall when shot | rail tracks with stops, `spawnAt`, the gun's ray and body parts, `ragdoll`, `occluder` meshes over a painting, `navFrom` | railshooter.forge, painted.forge | 91 |
| 10 | **Releasing your game** | the platformer as a `.game` in the menu, with a high score board | `forgeExport`, `--pack`, `games.dat`, the bezel, `submitScore`, what the machine at the arcade needs | -- | 92 |
Not tutorials, but a closing **Recipes** section, one paragraph each with a
sample to open: racing (`racing.forge`), tower defence, pool and water,
bowling, the maze, third person, space, rhythm and MIDI, the 3D adventure.
A recipe names the behaviours and the sample; it does not walk the keys.
### 13.5 Order of work
- **A. Foundations** -- `singeScreenshot(name)`; the kit drawn and packed,
extracted on first run, listed by the file picker; `forgeExport` copies
named files; the chooser's *Tutorials* heading; `== Tutorials` in
`Forge.adoc` with `:imagesdir:` and the first picture in the PDF to prove
the build embeds it.
- **B. Tutorials 1 to 3** and their scenes. These settle the voice and the
skeleton; the rest copy them.
- **C. Tutorials 4 to 7.**
- **D. Tutorials 8 to 10** and the recipes.
- **E. A read-through** of the reference chapters against the tutorials,
so every concept a tutorial teaches links to the paragraph that owns it,
and nothing is explained twice in two ways.
Each stage ends with `./build-docs.sh`, a look at Forge.pdf's pages, and the
refs sweep. The pictures are regenerated whenever the editor's chrome
changes, by running the tutorial scenes; a stale picture is a scene that
was not rerun, and the sweep catches a tutorial whose keys no longer do what
its text says.
### 13.6 Built (2026-09-14)
All ten tutorials and the recipes are in `docs/Forge.adoc`, with their
scenes 83 to 92 and the shipped descriptions in `forge/tutorials/`;
the kit is drawn and synthesised by `util/forgeKit.py` into
`forge/kit/` (the clip is 720 by 480, the overlay's size; a smaller
disc was thought to draw nothing, but that was the editor's opaque ground
leaking into the game, fixed since -- a 480 by 320 disc under a 720 by 480
overlay draws and scales as it should, checked 2026-09-14). What the tutorials forced into being, because
a reader could not otherwise do what the text said:
- `singeScreenshot(name)` in the engine.
- The kit stays inside Forge.game and is named `Forge/kit/...`; nothing is
extracted. The file picker lists it beside the game's directory.
- `forgeExport` (and `X`) copies every file a description names, at the
same relative name; the compiled script sets `AUTHOR_DIR` and the runtime
resolves names beside the game first (`authorFile`). The editor resolves
beside the description (`forgeFile`).
- The game's own form when nothing is selected: title, players, vars,
verbs, layers, and each layer's parameters, with tables typed as
`r=1, g=2` or `1,2; 3,4`. A scene3d layer typed in starts the viewport.
- `1` to `5` for the panels; `Delete` empties a field; a passed-over field
keeps its value (it used to be emptied); behaviours and layers typed away
and back keep their values (a stash), since the setter runs per keystroke;
new parts and behaviours fill in only what must be there (self, a key, a
switch), leaving numbers to their runtime defaults; the file picker starts
on the file already named or on `(none)`; exact picker matches sort
first (`miss` before `branchMissed`); the panel hides while a polygon is
drawn; in a 3D room `A` places on the floor under the viewport's middle.
- The runtime: the platformer sets `facing` and `state`; a flat game with
no disc paints its own dark ground; `scaleBy` scales only what stands on
the floor (anchor feet); text looks sort in front under `depthSort`; the
chooser lives in the library (`forgeChooser*`) and lists `Forge/tutorials/`.
- The compiler emits dialogue nodes in a fixed order, so the same
description compiles to the same text every time.
The pictures go into the book through `util/forgeShots.py`, which scales
them to 768 wide and reduces them to a palette (a full-window PNG per step
made Forge.pdf 7.5 MB; this keeps it small).
**Renamed** (2026-09-14, the owner's call): what an entity is made from
is a *type*, not a kind -- `types = { hero = ... }`, `type = "hero"` on an
entity and in an event's filter, the Types panel, `forgeType*` in the
editor -- everywhere at once, with no compatibility kept. `kind` stays as
the discriminator on a look, a behaviour, a layer, and a rule part
(`{ kind = "sprite" }`), which is the one thing it no longer collides with.
The manifest's parameter type is `type` too.
**Lists of records** (2026-09-14): a table parameter typed as text now
takes records (`name=head, bone=b_Head_05, w=0.5; name=body, ...`, one per
semicolon), maps of lists (`look=look|examine`), and lists of words, so
`target.zones`, `parser.words`, and `parser.ignore` are typed in the form
like everything else, and read back the same way.
**The sunk hero** (2026-09-14): two things. Every flat body was half the
size its look was drawn at -- the 2D branches of `solid`, `body`, and
`trigger` handed `bodyNew` halves, which takes full sizes -- so the ground's
top was ten pixels under the drawn top; and a player stands on its node
(the node is the feet, as `playerNew` says) while the look was drawn
centred on it, so the box sat half its height into the floor. Bodies are
full size now and a platformer draws with anchor feet; the starter's hero
says so and starts on the ground.
Scene 92 packs the release with `--pack` when the harness hands it the
engine's path in `SINGE_BIN`, and says so when it cannot. Packing found
that the export's `games.dat` named the script by its absolute path, which
`--pack` refuses: names in it are now the folder's own name first
(`CoinRun/CoinRun.singe`), the folder is named for the game, and the chooser
tells a packed `.game` from a description by the database header.
**The first desktop run** (2026-09-14, the owner's): entities select and
the panel drags, so the RmlUi pointer path is proven -- the open hardware
check from section 1 is closed. What it found: no key but the switch
ones reached the editor -- SPACE opened a game in the chooser because
controls.cfg maps it to a button, and ENTER and TAB did nothing -- because
Forge's launcher never asked for `MODE_FULL`, in which every key reaches
`onKeyPressed`; it does now (the runtime always did). Beside that, the
engine now hands TAB to a GUI document only while a text field is being
typed in, since RmlUi takes it for focus and keeps it; the drawn pointer
went under the panel (it is drawn after the GUI now); and a readout's box
was centred on its point while its text hangs from it (the editor's bounds
for a text look are its top left now, as the game draws it). The owner
runs Forge from a loose copy in the test library, which no build refreshes;
it was a day stale.
**The second desktop run** (2026-09-14): TAB worked; the mouse could no
longer select, and the cross was still under the panel. The mouse: in
`MODE_FULL` the engine handed a mouse or pad button to `onInputPressed` as a
keysym of 0 rather than as its switch, which no script could use; a button
cannot be typed, so it now arrives as its `SWITCH_*` value in either mode,
as the manual always said. The cross: the engine draws every GUI over the
overlay after the frame, so nothing drawn on the overlay can sit over the
panel; the cross is an element of Forge's own document now, moved to the
pointer each frame, and draws with the chrome and over it.
**Overlay layers round guiDraw** (2026-09-14, the owner's call): the
document element was the wrong answer -- anyone writing a mouse or gun game
would have had to fight the same thing -- so the engine now layers the
overlay round each `guiDraw`: what a script draws after the call goes on a
layer composited over that document, up to sixteen a frame, uploaded only
when drawn on. Forge's pointer is two lines drawn after `guiDraw` again,
and the manual's `guiDraw` entry and scene 93 (a box under a document, a
cross and a second document over it, a box over that) cover it.
**Rows and fields take clicks** (2026-09-14, the owner asked why they did
not): only the grip had a handler. Every row the panel lists is a div with
an id now, wired after the list is set to one dispatcher, `forgeRowClick`:
a row selects what it names, a picker's row takes it, and a field in the box
below starts typing it (`forgeEditField`). The keyboard stays complete, so
a cabinet loses nothing; scene 82 drives the dispatcher.
**Clicking a field, then typing** (2026-09-14): the owner could click a
field and nothing happened. The engine's `guiSetHandler` kept one listener
per id and never moved it: once a list was rebuilt through `inner_rml`, the
listener sat on an element that no longer existed, so every row of every
rebuilt list was deaf -- the entity rows included, from the first refresh
on. A handler follows its id now (the engine re-attaches when the element
behind the id has changed), scene 82 clicks rows through RmlUi's own
`DispatchEvent` rather than the dispatcher, and the "panel at" and "moving
the panel" messages are gone. A wrong turn on the way: `focus: none` on
the rows, meant to keep RmlUi from turning ENTER on a focused row into a
click, stopped every click instead -- RmlUi delivers a click to the nearest
focusable element under the pointer, so an unfocusable row cannot be
clicked at all. Rows are focusable; ENTER only becomes a click on an
element whose `tab-index` is auto, which no row's is. Proven with RmlUi's
own mouse processing driven from a scene: a click on an entity row selects
it, a click on a field starts typing it, and the typed text lands. Labels
in the box carry a colon, `id` shows as `ID`, and editable rows light up
under the pointer.
**Typing in place, and SHIFT** (2026-09-14): a value is typed in its own
row now, with a caret, rather than echoed on the message line; and SHIFT
did nothing because a keysym is the unshifted key -- the engine gained
`onTextInput` (SDL text input runs while a script has `MODE_FULL`), the
launcher feeds it to `forgeText`, and while typed text arrives the keys'
own characters are not typed as well (`FORGE.textInput`). The scenes still
type through keysyms, which is why they need no shift.
**Editing in place, and the way back** (2026-09-14): a value now starts
selected whole when its field is entered, so typing replaces it, and the
arrows, Home, and End put a caret into it to edit it (`forgeEditingOf`,
`forgeEditInsert`, `forgeEditBackspace`, `forgeEditDelete`,
`forgeEditCaret`); DELETE on the selected value empties it, which is what
the tutorials' "empty the entity" means. And the game's own form was out
of reach once anything was selected: the game's name at the top of the
panel and a press on an empty spot of the canvas select nothing now.
**Pickers for every fixed-set field** (2026-09-14): the owner: "Editing
items that have a fixed list of options does not display a list of options
-- you're expected to know what you're allowed to type. This is bad.
Dropdowns exist for a reason." `forgeFieldKind` says what a field is from
the manifest (its parameter type, or the words a `choices` table on the
manifest entry lists -- `anchor`, `faces`, a camera's `mode`, a light's
`type`, a body's `shape`, ...) and from the editor's own fields (a type's
look, a rule's event, its flags, an entity's type, a track's key, a
dialogue's nodes); `forgeFieldChoices` turns that into picker items and
`forgeFieldList` opens the picker, on the value the field has. Free lists
(type, room, entity, state, track, node) offer the typed text first, so a
name made later can still be typed, and a value the list lacks is offered "as it
is", so walking past the field with ENTER keeps it (the first sweep lost
`goTo.room = "cave"` that way, the room not being made yet). ENTER on a
row takes it and moves on to the next field, as ENTER on a typed value
does, so the form is still one ENTER per field; a click takes it and
stays. Choosing the value a field already has does nothing (choosing a
type's look again used to empty its parameters, and an event again its
filter). `tutorialDrive.form` reaches a field with a while rather than
a repeat, and leaves a list and then the field with ESC. A long list
windows `PICK_ROWS` rows around the chosen one. The owner then found the
list taking the panel's top list over confusing ("Isn't there a regular
drop down?"): a field's picker (`FORGE.picker.field`) now unfolds under
its own row in the fields box as a `.drop` element, absolutely positioned
with no top or left so it takes its static place and floats over the rows
beneath (`dropdown()` and `pickerRows()` in Forge.singe, `DROP_ROWS` 6);
only the manifest pickers (C, T, E) still take the list over. Then
"The dropdown should open upward when there's no room below. And what
about scrollbars?": the panel is a flex column whose list and fields box
share the height and scroll (`overflow-y: auto`, styled `scrollbarvertical`),
and `forgePlace` -- run at the start of every draw, on the first draw
after the frame a refresh happened in, since a key or click is handled
before that frame's draw and new elements measure nothing until the
engine's guiUpdate has laid them out -- scrolls the chosen row and the
edited field into view (`scrollTo`, nearest) and lifts a dropdown above
its row with a negative top margin when its bottom would pass the panel's.
The dropdown is hidden until placed, so it never flashes below first. The
RmlUi trap under all of it: the document body had no height, so the
panel's `height: 100%` was zero and everything in it was overflow -- which
is why `overflow-y: auto` on the panel "made it vanish" and a percentage
`max-height` collapsed; `body { width: 100%; height: 100%; }` fixed both.
A scrolling box is an offset parent, so a row's `offset_top` is relative to
its box (`offsetWithin` sums the chain). The vocabulary tables print the words instead of
"string" for a parameter with choices.
**The build owns Forge.game** (2026-09-14, the owner's rule): the release
build's `forge` target repacks `.builddir/Forge.game` whenever anything
under `forge` changes -- scripts, kit, tutorials -- and Forge.pdf is
rendered again whenever the book, its vocabulary, a tutorial's picture, or a
shipped description changes; both globs are re-run on every build, so a
new file counts too. Nothing about Forge is copied anywhere by hand.
Not done: cropping the pictures (full frames read fine at page width);
the light gun checks stand as before.
## 14. Porting the library (planned 2026-09-14)
The owner: "I think a really good test of this would be to port some
existing games to Forge projects." Then: "Ideally, we would be able to
port EVERY game to Forge and they'd play identically to the current
implementations." That is the goal of this section: every game in
~/claude/singetest (48 directories, 58 launcher entries) as a Forge
description that plays the same, proved rather than eyeballed.
### 14.1 What "identically" means, and how it is proved
A port and its original are run headless, on the deterministic clock, with
the same scripted inputs -- pointer positions, trigger pulls, moves, coins --
at the same disc frames, and a trace of what matters is compared: the disc
frame path (every search), the state or room each frame lands in, and the
score, lives, hits, and misses. The original is driven by calling its own
callbacks (onMouseMoved, onInputPressed) from a wrapper script that dofiles
it; the port by authorSetPointer and authorSwitchDown. A port passes when
the traces agree. Pixel-exact drawing is not the bar: positions come out
of the same numbers, but a text measured by a different font path may sit a
pixel off, and that is fine.
The comparison scenes live in testScripts/ports/, one pair per family,
and need the library beside the repo; they are not part of the reference
sweep.
### 14.2 The families
The library is not 48 different programs. It is four families and a few
one-offs, and a family is ported by a converter that reads the original's
own data tables and writes descriptions, so the port is mechanical and the
vocabulary Forge lacks shows up as a list, once per family.
1. **ActionMax** (5 entries, one Emulator.singe, 558 lines): a light gun
over VHS video. A shot is judged by comparing the brightness under the
gun with a sensor spot on the picture, sampled across two frames. Forge
lacked the sensor; it gains a `lightSensor` behaviour, a `pointer`
behaviour (a sprite that follows the player's pointer, hidden when the
engine says a real gun needs no crosshair), fonts on the text look,
anchors on it, sprites drawn in copies (a row of bullets), discPlay and
discPause actions, fire and miss clips on the sound behaviour, and
pointerX and pointerY in expressions. Converter: util/forgePortActionMax.py
reads each game's parameter file and the sprites' sizes and writes the
five descriptions beside the originals. First, because it is the
smallest complete game and every gap it finds is general.
2. **KarisFramework** (17 entries under KarisFramework/ plus the older
per-game copies of the same author's "LUA SINGE 1.1" in the Italian
Singe 2 games -- DragonTrainer, Conan, Daitarn3, SamuraiJack,
PussInBoots, DragonsLairTvShow, SuperDonQuixote -- 25 or so in all):
QTE laserdisc games. Data: Level[] tables (name, frames, death and
resume frames), Death[] clips, Cfg/s*.cfg move lines (one encoded line
per scene: frame, moves, mash and skip flags), menus at video frames,
difficulty penalties, tiers and random order, map mode, trophies, hints,
high scores, saves. Forge has the branching track, discTo, lives,
credits, the results board, and submitScore; it will need the move
line's extras (several moves in one window, mash, skip), difficulty as a
var the windows read, an order of levels (sequence, tiers, random) as a
track of rooms, and menus driven by video frames. Converter:
util/forgePortKaris.py, reading the game's settings file and Cfg/ and
writing one description; the framework's menu and service screens are
Forge's own. The largest family and the largest payoff: one converter,
twenty-five games.
3. **American Laser Games** (Mad Dog McCree 1 and 2, Crime Patrol 1 and 2,
Space Pirates, Who Shot Johnny Rock, The Last Bounty Hunter; each in a
Singe 2 and an HD edition, 14 entries; Platoon by the same hand):
light gun over video with hitbox tables per scene
(Script/hitbox-*.singe: boxes by frame), a scene graph of success and
failure clips, service menus, high scores. Forge has hitbox tracks over
video and the gun; it will need the scene graph as rooms with rules on
frameReached, and whatever the service and manage scripts do that a
player can see. Converter: util/forgePortALG.py, reading the hitbox
files and the scene tables.
4. **One-offs**: Rollercoaster (a text adventure over the disc: the
parser layer, rooms, discTo), Hologram Time Traveler (gun and branching,
6k lines), LINEA (MazescaterFramework), BatMaker (a tool that writes
launchers, not a game: not ported). Each by hand, after the families,
using what the families built.
### 14.3 Order of work
ActionMax first (this section's first entry below), then Karis, then ALG,
then the one-offs. Each family: read the original; list the vocabulary
missing; add it to the manifest with a scene of its own; write the
converter; run the comparison; fix what differs; note what was learned
here. A family's converter is kept in util/ and re-run whenever the
manifest changes, so the ports never drift from the editor.
**The extension** (2026-09-14, the owner: "You're making a lot of
Forge-related files with the extension '.game' that are just ASCII text.
We're already using '.game' for our packed releases."): a description is
a `.forge` file from here on -- the tutorials, the samples, the ports, what
Forge saves, and the copy a release carries -- and `.game` is the packed
release alone, so the chooser no longer sniffs a database header to tell
them apart. Earlier sections say `.game` where they meant a description;
read `.forge`.
### 14.4 ActionMax, ported (2026-09-14)
Done: util/forgePortActionMax.py writes the five descriptions beside the
originals in the library (38AmbushAlley.forge and the rest), and
testScripts/ports/actionMaxOriginal.singe and actionMaxPort.singe run the
emulator and the port on the inputs in actionMaxInputs.singe -- a pull on
the title, a pull on the menu's left half, forty shots at three spots the
picture flickers on and a few elsewhere -- and print the same trace: every
state change with its disc frame, and at every input the state, the disc
frame, and the shots, good hits, and bad hits. .38 Ambush Alley's traces
agree line for line (one good hit among the forty), and Blue Thunder's.
What the port taught, all of it general:
* **The overlay size is part of "identically".** The emulator lays itself
out on overlayGetWidth(), which is the engine's default over a disc --
half the video, 360x240 -- since a launcher entry's RESOLUTION keys size
the window, not the overlay. Every number in the game (the sensor spot,
the corner shots are not judged in) assumes 360x240; a port declares
`size = { w = 360, h = 240 }`, and the original's wrapper must not set
the overlay to anything else. The first comparison, at 720x480, found a
"good hit" that was the sensor reading the wrong place in both.
* **An event belongs to the room it happened in.** A press on the title
that goes to the menu was also a press in the menu, in the same dispatch,
and started a game; matches() now compares the rule's room with the room
the event was raised in, and a frame's rules run for the room the frame
began in (frameReached too).
* **A built game finds files beside its description.** AUTHOR_DIR is the
built script's directory, which is the data directory for a description
compiled where it lives; the compiler now also writes AUTHOR_SOURCE_DIR
and authorFile looks there second.
* **Vocabulary:** lightSensor (the two-frame judgment, raising hit, miss,
and fire; moved into the games' own Vocabulary.singe on 2026-09-15, see
14.7), pointer (a crosshair that follows the pointer, hidden when
singeWantsCrosshairs says a real gun needs none), the text look's font,
size, and anchor (centre, feet, top), the sprite look's copies (the var)
and step, discPlay and discPause, fire and miss clips on sound, and
pointerX and pointerY in expressions. Scene 96 (overlayBits.forge) covers
the general ones from the repo alone.
* **The wrapper for an original** sets what its launcher entry would have
(SINGE_LEGACY_SPRITE_ARGS) and answers singeGetScriptPath with the
original's own path while it loads, so MYDIR is right.
* The two-frame sampling judges few shots: in forty aimed at flickering
spots, one. That is the emulator's own behaviour and the port's.
* **The editor opens a port**, and scrubs a frame file's video: the disc
layer of an ActionMax game names frame_<game>.txt, which lists videos by
first frame; `frameFileVideo` picks the one with the longest run (the
game's) and `FORGE.videoStart` puts the cursor's disc frame on the right
picture. Not done: the editor's canvas stays 720x480 whatever the
game's `size`, so a 360x240 game is drawn at its own coordinates in the
top left quarter -- right, but small.
* **Esc on a passed-over field wrote nothing back after all** -- it used
to "put back" an equal value, which for a type's vars turned nothing
into an empty table, and the shipped tutorial descriptions carried those
tables. The sprite look's new `step` parameter moved which field the
tutorial's Esc landed on, tutorial 3 stopped matching, and both ends
were fixed: `forgeEditCancel` sets only what the typing changed, and
the compiler leaves a type's empty vars table out, so a description with
one and one without compile the same.
### 14.5 KarisFramework, ported (2026-09-15)
Done: all seventeen entries (Time Gal, Dragon's Lair Enhanced, Dragon's
Lair II Enhanced, Space Ace Enhanced, Asterix, Cliff Hanger, The Elder
Scrolls, Oeil pour Oeil, Altered Carbon, Tron, Friday the 13th, Mononoke,
Ninja Hayate HD, Titan A.E., Sucker Punch, Fire and Ice v1 and v2,
Chantze's Stone and Triad Stone) play as their originals do under the same
driver: testScripts/ports/karisDrive.singe presses START on the attract
loop, answers every move three frames into its window -- the right answer,
or a wrong one for every fifth move -- takes the one continue, and traces
every disc seek and every change of screen, step, level, scene, move,
score, and lives. All seventeen traces agree line for line, including the
life-bar game type (Tron), the in-game difficulty screen (Space Ace, Tron),
the level maps with a cursor (Sucker Punch, Friday the 13th), the random
level orders (tiers in Time Gal, Dragon's Lair's shuffled last levels),
the mirrored scenes, and each game's own scoring add-ons.
How:
* **The converter runs the game's own script.** util/forgePortKaris.lua
loads a game's settings script with the engine stubbed out, so every
table it declares (Level, Death, Tiers, PlayOrder, the offsets, the
scores, the dips from Cfg/game.cfg or default.cfg, the high score board)
is read as data, and setupMoves is called for every level and scene at
every difficulty for a snapshot the editor can show. The description
(`<Game>.forge`, beside the game) carries the snapshot under `qte` and
names the script and Script/addons.singe.
* **The runtime is the framework's loop, with the framework's names.**
The `qte` behaviour in Author.singe keeps its state in one table under
the framework's own variable names (iScore, iLives, currentMove, move[],
stage[], SCOREMOVE, offsetGetReady, dip_Difficulty...) and plays the
framework's states with the framework's numbers (levelNormal = 103,
branch02 = 11...). A shim environment whose globals are that table runs
the game's script and add-ons verbatim, so Space Ace's setupMoves reading
which levels are beaten, Dragon's Lair's startConf shuffling the last
levels and its per-move specialScore, Sucker Punch's map, and Space Ace's
random game-over and death clips all behave as written. The framework's
own functions the add-ons call (timerON, setupClip, soundPlay, spriteDraw,
joyDelayDue...) are shim helpers.
* **The random stream is shared.** The loop calls math.random exactly where
the framework does (the level order, each scene's mirror, a random death,
a choice's shuffle on its first drawing) and seeds from KARIS_SEED when a
comparison sets it, so both runs draw the same numbers.
* **A game hears the switches.** An event about nothing in particular (a
press, a release) now reaches behaviours that set instance.hearsAll, which
the loop does to read the framework's p1 flags.
* **A built script must not be the game's own script.** The loop loads the
game's files from beside the description first: building Timegal.forge
to Timegal.singe in the same place loaded the built game inside itself
and overflowed the C stack.
* **The framework never calls discPlay.** A seek resumes a paused disc, and
a discPlay a frame before the seek moved two games' traces by one frame.
* **The frame clock and the CPU clock.** The framework's timers are
os.clock; the port's are the game clock. The traces compare seeks and
states, not times, and the driver acts on disc frames, so the difference
never shows -- except that a screen left "after 120 seconds" costs two
real minutes in the original and two virtual ones in the port.
Not done: the high score board's name entry (the port plays its clips and
enters nothing), the service menu, saves and loads, two-player mode, and
the drawing -- the port draws no arrows, LCD text, or figures yet; it
exposes score, lives, credits, level, and prompt as vars. The Italian
Singe 2 games (DragonTrainer, Conan, Daitarn3, SamuraiJack, PussInBoots,
DragonsLairTvShow, SuperDonQuixote) are the same author's earlier
framework with per-game copies and a different level structure
(createLevelNN, segments); they are the next family.
### 14.6 What the rest of the library is, and a decision to make (2026-09-15)
The two families ported so far were data-driven: a script full of tables,
one shared loop. The rest is not:
* **The Italian Singe 2 QTE games** (Dragon Trainer, Puss in Boots, Samurai
Jack, Daitarn 3, Conan, Dragon's Lair TV Show, Super Don Quixote) each
carry a *copy* of the older KarisFramework (LUA SINGE 1.1) beside their
data, and the copies are edited per game: Daitarn's by 29 lines, Conan's
by 560, the TV Show's by 1,787, Super Don Quixote's by 7,907 -- the
framework is part of the game. The loop is a simpler ancestor of the
one now in Author.singe (segments, per-level offsets, a 2,000-line
setupDeathClip that maps each death to its offsets by hand).
* **The American Laser Games titles** (Mad Dog McCree I and II, Crime
Patrol I and II, Space Pirates, Who Shot Johnny Rock, The Last Bounty
Hunter, each in two editions, and Platoon) share a skeleton (move tables
with hitbox ranges, hitmap arrays by frame, bullets and reloads, an
undertaker) but every scene is a hand-written function -- doLevelSaloon,
doLevelBank, twenty to forty per game -- with its own branches, items,
and clips. There is no table to convert; the scene logic is the game.
* **The one-offs** (Rollercoaster, Hologram Time Traveler, LINEA) are
bespoke scripts.
Two ways to make these Forge games that play identically:
1. **Reimplement each loop and rewrite each scene** as Forge rules and
tracks. Faithful only if every quirk of every hand-written scene is
carried over by hand; for the ALG family that is some two hundred scene
functions, each to be compared move for move. Months.
2. **Host the game's own Lua** inside a Forge behaviour, as the qte
behaviour already hosts a Karis game's script and add-ons: the
description carries what Forge understands (the disc layer, the gun,
hitbox tracks converted from the hitmap arrays so the editor can show
and edit them, the release path), and a `hosted` behaviour runs the
game's scripts in a shim with the engine's callbacks routed through it.
Identical by construction; the port's value is Forge's structure
around the game, not a rewrite of it. Days.
The owner should say which of these counts as a port. Until then the
order stands as written: the Italian family next (its base loop is small
enough to reimplement, and its per-game copies could be hosted for the
differences), then ALG.
### 14.7 A game's own vocabulary (2026-09-15)
The owner asked whether a game's author can extend the kinds, looks, and
behaviours without putting one game's hand-written piece into every game's
Forge; the light sensor was the case in point. Yes, now:
* A description may say `vocabulary = "Vocabulary.singe"`, a file of Lua
beside it that adds entries to `AUTHOR` exactly as Author.singe does (a
`bob` behaviour is `AUTHOR.behaviours.bob = { params, attach, step }`;
a condition or action gives `emit`). The file may define any globals
its entries emit or call.
* `authorVocabulary(name, folder)` in Author.singe loads it -- beside the
description first, then beside the built script, through the shared
`authorBeside(name)` that the qte loop's script loading now uses too --
and records what it added; `authorVocabularyForget()` takes that out
again. `authorKeyValue` and `authorSwitchValue` are public now so a
vocabulary's behaviour can resolve a key or switch name.
* The compiler loads it before `authorCheck` (so the checker knows the
words) and emits `authorVocabulary("...")` after `AUTHOR_SOURCE_DIR` in
the built script, so the game loads it wherever it runs.
* The editor loads it in `forgeBegin` (forgetting the last game's), forgets
in `forgeClose`, has a `vocabulary` field (kind file, `.singe` listed) on
the game's form that reloads as it is set, and `forgeFilesNamed` takes
the file so a release carries it.
* The light sensor and its pixel reader left Author.singe for
`~/claude/singetest/ActionMax/Vocabulary.singe`; its trigger is a
`pressed` event heard through `hearsAll` and read in `on`, so the switch
handler in the core no longer knows about sensors. The converter emits
the `vocabulary` line; all five ActionMax compares still agree.
* Scene 97 (`testScripts/author/vocab.forge` + `vocab.singe`) covers it
from the repo alone: the editor knows `bob`, `bobbing`, and `setBob`
while the game is open and not after, the built script names the
vocabulary, and the game plays by it. The book has "Your own
vocabulary" under The Vocabulary.
### 14.8 The ports in a project folder of their own (2026-09-15)
The owner asked for the ported games in their own folders under a project
folder parallel to the library: `~/claude/singePorts/`. `util/forgePorts.py`
assembles it from the library and builds it, and is the thing to rerun when
a converter, a description, or Forge changes:
* One folder per game, named as the library names it (the two-game folders
ChantzesStone and FireAndIce keep both games); the five ActionMax games,
which share one folder in the library, get one each with their own video,
frame file, and cabinet art plus the family's sprites, sounds, fonts, and
Vocabulary.singe.
* A KarisFramework game's folder travels as it is, less the framework copy
and the original games.dat; the game's own script comes because the qte
behaviour plays it. The ActionMax emulator and original scripts do not.
* Video is hard-linked, not copied (the library's videos are never edited
and copying them is some forty gigabytes); everything else is a copy.
* Each folder gets the compiled port as `<Stem>.port.singe` (never the
game's own name -- the built script would load the game inside itself)
beside a copy of Author.singe, compiled by the engine running
AuthorCompile from a temporary directory with Forge beside Singe, and a
games.dat made from the original's entry: the port as SCRIPT, the
folder as DATA, LEGACY_SPRITE_ARGS off (the port speaks the engine's
own API through the shim).
* The README in the folder says all this in the owner's terms.
Every port launched from its games.dat headless for twenty-five seconds
without an error (`testScripts/ports/launch.sh`: Xvfb at 1920x1200, since
SDL's offscreen desktop is smaller than the Karis games ask for, and the
deterministic clock). The originals in the
library are untouched; the descriptions beside them are what the tool
copies, so the converters still write there.
### 14.9 The Hypseus library (2026-09-15)
The owner asked about the Hypseus games, with the end in view: port as many
as possible so the originals can eventually go. `~/claude/singetest/hypseus/`
holds 60 title folders (169 GB, the upstream zip-ROM layout:
`<Title>/singe/<Title>/` for the scripts, a framefile and `Video/` beside).
Fifty-six have scripts; four are video-only alternates (two Hayates, two
Time Gals). They are seven families:
1. **Karis Framework 3.32b** (11): Arcade Xperience 1, 2, and 3, Astroboy,
Danmachi, Freddy, Starship Troopers, Sugar Rush, Survival, Esh's
Aurunmilla, Badlands Lite. The library's copy is 3.31c with the
owner's patches; 3.32b adds a language dip (an audio suffix), a
hold-to-loop dip the loop already knew, a fourth-button secret
combination, and a free-play guard on the two-player start. **Ported
today:** the converter finds the framework where a game keeps it
(`KarisFramework/Script`, `Structure`, or `../Framework`) and writes its
version into the description; the loop reads the version for the
secret combination and maps the fourth button; `compare.sh hypseus
<Title>` stages a run directory with the title's `singe/` beside it
(the scripts hardcode `BASEDIR = "singe"`); `forgePorts.py` flattens a
title into `singePorts/<Title>/` with its framefile and video and
writes the games.dat it never had. The comparison found an engine
bug rather than a port bug: Freddy, Starship Troopers, and Sugar Rush
died, original and port alike, at their map screen's
`musicStop(sndHandle)`, because Singe's `musicPlay` answered nothing
where Hypseus's answers the handle; it does now, and `musicStop`
takes a nil handle as none. The earlier Hypseus sweep ran forty
seconds a game and never reached the map. With the fix, all eleven
traces agree with their originals, and every one of the thirty-five
ports in singePorts launches from its games.dat.
2. **RDG's LUA SINGE 1.0 and 1.1, the map-mode lineage** (4 here, 7 in the
library): Freedom Fighter, Starblazers, Jeeg, HQ Time Gal here, and
the Italian Singe 2 games there (Dragon Trainer, Puss in Boots, Samurai
Jack, Daitarn 3, Conan, Dragon's Lair TV Show, Super Don Quixote). One
ancestor loop (`main.singe` of some 4,000 to 6,500 lines with
`map.singe`), copied per game and edited a little in most and a lot in
some. Future Boy and Road Blaster carry later Karis versions (3.31c
with a map, and 2.2) in `Script/`, closer to this lineage than to the
3.32b loop.
3. **Kimmy Script Engine 4.0** (7): Brain Dead 13, Future Boy Conan
(Kimmy), Mazinga Z, Fuma Conspiracy, Sonic 1996, Sintel, Hayate 1080.
Karis's 2024 successor to the framework: a 5,900-line `main.singe`
and a 4,900-line `moves.singe` (combos, skins, an LED panel), three
versions across the set. Data-driven like the framework it grew from.
4. **American Laser Games, two-player HD editions** (11): Crime Patrol,
Drug Wars, Last Bounty Hunter, Mad Dog I and II, Space Pirates, Who Shot
Johnny Rock (twice), Johnny Rock Noir (twice), Mad Dog II Typing
Edition. The same skeleton as the library's ALG family, with the
hitbox files reworked for two players.
5. **Singe 1 monoliths** (7): Time Gal, Ninja Hayate (twice), Esh's
Aurunmilla (the SBC port), Scrat -- each a 12,000-to-16,000-line
`main.singe` of its own, no two alike -- and Cracke and Triad, an
Italian 476-line framework plus a game.
6. **Widge's and Luthergond's games** (11): Cops, Fast Draw Showdown,
Gallagher's Gallery, Marbella Vice, Platoon, Tierras Salvajes (the
"multiplayer editions": a 35-line launcher and a `.bin.luac`, which
`singetest/tools/decompile` turned back into source), Captain Power
(four sub-games), Minesweeper, Rick's Revenge, Space Rocks, and SEGA
Video Driver (seven sub-games on one engine). Not laserdisc QTEs:
overlay games that happen to use the disc, each its own program.
7. **Data only** (3): Esh's extras, Road Blaster's screens, the Video
Driver batch files.
What this means for the end goal. Family 1 is done by the existing loop.
Families 2 and 3 are the framework kind: a converter reading each game's
tables and one more loop per lineage in Author.singe, as the qte loop was
built -- days each, and they cover eleven and seven games. Families 4, 5,
and 6 are hand-written programs (twenty-nine here, and the library's ALG
and one-offs make it forty or so): no table to convert, so the choice in
14.6 decides them. Hosting the game's own Lua inside a Forge behaviour
still lets the original folder go -- the script travels with the port, as
the Karis games' scripts already do -- and it is the only route that is
not months. The owner's answer was the goal "complete the Hypseus ports,
starting with the Kimmy Script ones", and 14.10 is how: on a closer look
the Kimmy engine went the hosted way too, because its three versions
across the seven games are twenty thousand lines each and differ from one
another, and a transcription of one would play the other six wrong.
### 14.10 Hosting, built and withdrawn (2026-09-15 to 2026-09-16)
A `hosted` behaviour was built that ran a game's own Lua whole, framework
and all, inside a shim, and fifty Hypseus titles were "ported" with it and
shown to play as their originals. The owner's verdict on seeing what it
was: "a game that we can't really edit that carries the burden of
another framework." Right: a wrapper, not a port -- nothing in the
description to edit, the framework and scripts still in every folder. It
was withdrawn the next day: the behaviour, its draw hook and fan-outs,
the disc layer's `paused` flag, the converter, the drive, scene 98, the
book section, and the fifty descriptions are gone, and those originals
are back in singetest/hypseus. What stays from the episode: `authorFan`
(one fan-out for the instance-less events), the Karis converter's
framework search and version, `musicPlay` answering its handle, and the
Singe framework lines added to the Captain Power and Video Driver scripts
and Sintel's framework copy (library fixes, not port artefacts).
The direction now, the owner's choice: drop hosting; port the two
framework families properly (a converter reading each game's tables and a
loop of Forge's own per lineage, as the Karis family was done); then
rewrite the hand-written games too, one by one, in Forge's terms, each
with a comparison harness of its own.
### 14.11 The Kimmy Script Engine, properly (2026-09-16)
**What it is.** Karis's 2024 successor to the framework the qte loop
plays: the same state machine (gameflow, lvlState with the same branch
numbers, the same level screens, Level[] and Death[] tables, setupMoves
per scene, Cfg/game.cfg dips, a Script/addons.singe), with a new move
system in a file of its own, moves.singe. A move is judged by a
seven-bit mask of what is held (acombo, from the input handlers) against
the mask the move asks for (gcombo, from fillMove), which gives combos
(two inputs at once), diagonals, and a second accepted answer; mash has
up/down, left/right, and button-pair variants with counters scaled by
difficulty and window length; hold wants the combo held for a computed
length; multi and loop are sequences (loop draws a wheel sprite chosen
by the sequence's shape); path branches by direction with jumps to a
later move (iPathAend/iPathAjmp) or a death (iPath > 1000); yes/no and
timed branch too; way, wayout, toscene, and tolevel are jumps without an
input; anything and nothing are what they say. Around that: tilt (a
count of presses outside a window that kills at a threshold, with a
warning), a life bar for game type 1, rewind modes 0/2/3 on death, a
"next" hint of the coming move, hints on death, 2P alternation, a level
select screen (dip_PlayStyle 4), difficulty 0 simplifications and
MashtoRun/HoldtoLoop rewrites at setup (setupFramesMoves), and mirroring
(bFlip) of every move kind. Seven games; their frameworks are three
versions (BD13's; FBC's; mazinga/Fuma/Sonic's) that differ by ten lines
of main and eighty-four of service from one another, plus hayate_1080's
Structure copy (a later 4.0 with an LED panel and a Menu folder, 358
lines of main and 577 of service apart), plus FBC's and Fuma's own edited
copies of main/service/toolbox in their Script folders (6/49/1 and
39/20/0 lines). The differences are catalogued below as they are read.
**The design.** A `kimmy` behaviour beside `qte` in Author.singe,
sharing everything of the Karis loop that Kimmy left alone (the Q
helpers: timers, sounds, the shim, the script and add-on loading, the
scoring and beat-game tests, the mixes and orderings, the continue, game
over, and difficulty screens, the high-score board) and carrying its own
K functions for what Kimmy changed: K.doLevel, K.doMove and the checks,
K.fillMove, K.setupFramesMoves and K.getShortcuts, K.resultMove and
K.resetVar, K.startGame, K.doIntro, K.doFinish, K.levelReplay,
K.nextLevel, the level select and save screens, the input handlers with
the combo masks, and the drawing (in Forge's terms: text and boxes, as
the qte loop draws, not the framework's sprites). The converter grows a
Kimmy mode: it finds the framework at ../FrameworkKimmy or Structure,
stubs the Hypseus calls the framework makes at load, snapshots the
same tables plus the path/timed/multi ones, and writes
`kimmy = { ... framework = "4.0" ... }`. Per-version and per-game
differences that a player can see go under version flags in the loop
where they are a line or two, and into the game's own vocabulary file
where they are a game's own thing (FBC's and Fuma's edits, hayate_1080's
LED panel). The comparison drive grows a Kimmy policy that answers
every move kind: hold for the computed length, mash at the counter's
rate, the sequence for multi and loop, a direction for path, the button
for yes/no, the timed input in its window.
**Order.** Brain Dead 13 first (the reference framework, the fullest
game), then mazinga, Fuma, Sonic (the second version), FBC (the third,
with its own edits), Sintel, then hayate_1080.
**Built (2026-09-16).** The `kimmy` behaviour is in Author.singe after the
qte loop: the KIMMY constants, the masks, the tests (K.checkBasic,
checkCombo, checkHold, checkLet, checkMash and its up/down, left/right,
and button-pair variants, checkMulti, checkAny, checkNo, checkSkip),
K.doMove with the path, yes/no, timed, and skip branches, K.getShortcuts
(the shorthand kinds and the loop, multi, path, and yes/no texts read into
tables), K.setupFramesMoves (game type 5, difficulty 0, MashtoRun,
HoldtoLoop, mirroring, the play frames), K.setupFrames and setupLevel,
K.addPoints (nothing for die-and-retry), K.newScore (deaths against the
difficulty's record), K.setupDeathClip (no life lost in die-and-retry,
the 2P bookkeeping), K.nextLevel (MazeGame keeps the level), K.levelReplay
and onToNextLevel (play style 4 goes to the level select), K.doLevel with
K.failed, afterDeath, sceneDone, enterScene, and resumePlay, and the
screens: K.startGame, doIntro (fifteen-second stills, the alternate
rankings still, START to the new-game menu in free play), doNG and
updateNG over the description's snapshot of the save slots (K.loadSave,
startSave, startMap), doLvlSelect and moveFrameLevel, doClear, doFinish,
doContinue, doGameOver, doHighScore, doDiffSelect, and doExit. What is
not played: the movie player (game type 6), the two-player screens
(START2), the service and save menus, drawing. Q.doChoose is shared
(it takes the loop's own addPoints). The converter finds
FrameworkKimmy (and a Fuma-style Script/globals.singe), marks the
description's behaviour `kimmy`, and snapshots the die-and-retry
records and the six save slots; the drive plans a window's presses by
kind (a tap, a pair, a hold to the window's end, a hold let go for
LETGO, taps every other frame for a mash, the sequence for multi and
loop, a path's first direction, the button for yes/no, the timed
input in its window) and answers the new-game and level-select menus;
`compare.sh` puts a game's Cfg folder back after the runs, since the
originals autosave into it. Brain Dead 13's new-game menu runs under a
level number the framework never defined (`levelNG` is nil in Kimmy's
globals), which the port reproduces in its trace.
**Native mash and hold (2026-09-16).** The owner's aside: "People love
Mash for some reason. Should be native to moves available in Forge."
So they are: a branch on Forge's own `branches` track may say
`mash = 6` (the move that many times before the window closes) or
`hold = 20` (the move held that many frames of the window), and
`branchOpen` carries `event.mash` and `event.hold`. Scene 98
(`testScripts/author/mash.forge`) covers both. The Karis and Kimmy
loops keep their own mash arithmetic, since a port must match its
original's; a game written fresh in Forge gets the native kinds.
**The Hayate 1080 variant (2026-09-16).** Its Structure copy of the engine
(an LED panel, an arcade mode, a menu folder) differs from Brain Dead 13's
in things a player can see, gated in the loop by `q.hayate` (its settings
declare ArcadeMode): START sets the start level and scene to one in
arcade mode; a level's first scene forces rewind mode 0; the "nothing"
move with a second answer scores nothing; the coin prompt sound is not
played from the level (that copy plays a prompt sound from its drawing
instead, under its show-action setting); a level's end always clears the
scene's death count; a continue restarts the stages at scene 0, move 1;
the extra life comes regardless of a second player; credits count to 99.
**Results (2026-09-16).** Brain Dead 13 plays as its original for the full
sixty thousand frames of the comparison (1,662 trace lines: the new-game
menu, the intro clips, play across the maze's levels in die-and-retry
mode). Mazinga Z and Sonic 1996 agree at six thousand frames; Future
Boy Conan (FBC) and Ninja Hayate 1080 agree through a whole game -- lives
spent, the continue taken, back to the attract loop -- which is the
drive's own end. Fuma Conspiracy's first run sat on its attract loop: it
is not free play, and the drive had only ever pressed START (the Karis
3.32b titles were all free play); the drive now inserts a coin first
when the game wants one. Sintel's framefile is beside its script rather
than at the top of its folder, which the harness now allows; and its
script was the one Kimmy game that never loaded Singe/Framework.singe, so
its original died on a Hypseus call -- it has the two lines the other
games got from an earlier library fix. With those, Fuma Conspiracy and
Sintel agree through a whole game too. Mazinga Z and Sonic 1996 then
agreed through a whole game as well. All seven Kimmy titles play as
their originals do; they moved to singetest/ported/hypseus, and
singePorts carries them: 42 ports in 40 folders now (24 from the
library, 11 Karis 3.32b, 7 Kimmy). The library Karis family and the
3.32b family were regressed over the shared changes (the choose screen
and the drive) and still agree.
### 14.12 The RDG map-mode lineage (surveyed 2026-09-16)
**What it is.** RDG's "LUA SINGE 1.0/1.1", the ancestor of the Karis
framework: the same gameflow and lvlState machine (lvlSetup, lvlRunning,
lvlEnd, lvlPlayDeath, lvlPlayRest, branch01 to branch10), the same
screens (intro, continue, game over, high score, service, movie, finish,
save), and a doLevel of some seven hundred lines. A level is a stage of
segments: `createLevelNN` fills `stage[]` and `segment[]` (a segment's
id, done flag, and title), `setupLevelNN(segment)` sets the segment's
frames and its `move[]` table (a move is `{ start, end, kind, death, 0,
0 }`, the kinds the eight directions and buttons, MASH, the four ACT
combos, and SKIP), `SetupFramesLevel` adds the level's disc offset and a
difficulty shift, `NextLevel` walks a fixed order or goes to the map,
and `map.singe` is the level select over a still with a cursor. A game
is its own copy of all of it: the level functions live in main.singe,
not in a game script. The copies drift: freedomfighter is starblazers
with 149 lines changed (a mouse-driven variant); jeeg 1,106; hq-timegal
is another structure again (hq-*.singe); in the library, Dragon Trainer,
Puss in Boots, and Samurai Jack share one copy exactly, Daitarn 3 is 28
lines off it, Conan 315, the TV Show 881, Super Don Quixote 4,425.
Starblazers adds a mouse-picked level jump (choiceMode).
**The design.** The same shape as the Karis and Kimmy ports, with one
more thing run in the shim. Where a Karis game's script declares tables
and a setupMoves, a map-mode game's main.singe declares its data as
functions: createLevelNN (the segments), setupLevelNN(segment) (the
moves, some chosen by a random draw), SetupFramesLevel (the level's disc
offset and the difficulty shift), getIntroClip (the intro clip per
level), setupDeathClip (the death number to its clip, two thousand lines
of it in the Italian copies), NextLevel (the fixed order and a game's
own jumps), initStages, and the map's setCursor and moveCursor (the
layout). Those run in the shim as they are, over the loop's state, as
the Karis games' setupMoves and add-ons do -- the loop calls them where
the framework calls them -- and the loop itself (doLevel, the judging,
the screens, the input) is Forge's `rdg` behaviour, a transcription of
the ancestor's, with the copies' differences gated by what the game's
script declares. The converter loads the game's files with the engine
stubbed, records the settings and the dips, and names the script and
its files; the moves are not snapshotted, since a random draw picks
some of them at play time and the game's own functions must make that
draw. The random stream is the game's: the scripts seed it from
os.clock at load, so both drivers pin os.clock to the frame count (the
hosted drive did), and the loop makes the same draws in the same order.
The eleven games go in order of drift: starblazers, freedomfighter (a
light-gun variant with checkZone and getPixelState), jeeg, hq-timegal
(a different structure again, to be read); then the library's Dragon
Trainer, Puss in Boots, and Samurai Jack (one copy), Daitarn 3, Conan,
Dragon's Lair TV Show; Super Don Quixote is a hybrid of this lineage
and Karis 3.x (checkHold, checkLet, doClear, doDiffSelect, getRes) and
comes last, gated or with a loop of its own.
**Built (2026-09-16).** The `rdg` behaviour is in Author.singe after the
kimmy loop: R.doLevel (the ancestor's fourteen states), R.doIntro,
startGame, doContinue, doGameOver, doFinish, doHighScore, startMovie,
checkMash, checkSkip, scanInput (which lets every input go, as the
ancestor's does), and the pair test for the button-with-direction and
two-direction kinds. The shim gained two switches the behaviour sets:
`loadsFiles` makes its dofile load the game's own files beside the
description (globals, main, map, hscore, service, toolbox), and
`namedSounds` makes its soundLoad answer the file's name, so the game's
`sndright = soundLoad(MYDIR .. "right.wav")` becomes a handle the loop
plays by name. The converter's rdg mode (a globals.singe beside the
script and no framework folder) reads the dips and the board from the
game's game.cfg and snapshots no levels, since the game's own functions
make them at play. Library fixes on the way: the three Hypseus titles
play a prompt sound they never load, which Hypseus let pass and Singe
reports, so their scripts let a nil handle pass; the flat titles reach
their scripts through a symlink the harness now follows. starblazers,
freedomfighter, and jeeg have descriptions; the first comparison is
running. On the way: a map-mode
script sets `MYDIR = "singe/starblazers/"` and joins file names straight
onto it, and the shim had been answering MYDIR with Forge's own directory
without a slash; it keeps the game's value apart now and answers with the
slash the game's ended in. hq-timegal is a variant of its own (a
chooseLevel with tiers, a time-stop, setupLevel by difficulty arrays, no
segments or map), to be read once the three others agree. starblazers' port then played as its original for
six thousand frames on the first run that reached play (after the move
table's absence at attach and the unnumbered finish screen, which the
lineage never defined either, were seen to), and then for the full run.
jeeg followed once the converter read the right file: a title's dips and
board live in the file its service.singe opens (game.cfg, or the game's
own name, jeeg.cfg), which the converter now takes from that line; and a
title without a rewind dip (jeeg has none) rewinds as the ancestor's
zero. (The Italian scripts carry Latin-1 bytes, which ugrep takes for
binary and skips silently: grep them with -a.) freedomfighter is the
light-gun variant: its original drew the crosshair at coordinates the
mouse had never set, which Hypseus let pass and Singe reports, so the
library copy starts them at zero; a down action whose row carries a
zone (columns 7 to 10) is a shot, right when the button is down with the
pointer inside the zone, silent, and the disc skips to the window's end;
the loop hears the pointer as the ancestor's onMouseMoved did (Author
fans "pointer" to the loops that hear everything, and the rdg loop keeps
mouseX and mouseY by the game's ratios). What a title does differently
without declaring it -- freedomfighter's right move sounds sndshot and
its command screen holds fifteen seconds -- the converter names in a
per-title `tweaks` table the description carries and the loop reads; the
harness views hand the drive the zone and the ratios, and it puts the
pointer in the zone (or well outside it for a wrong one) the frame before
the button. All three then agree for six thousand frames and for the full
runs, and moved to ported/hypseus (forgePorts assembles a flat title from
its own folder, less what Hypseus put there).
**The later copies (2026-09-16).** Dragon Trainer's generation (Puss in
Boots and Samurai Jack share its main.singe exactly, Daitarn 3 is 29
lines off, Conan 560, the TV Show 1,787) declares a level order
(LvlOrder), and the loop takes that as the mark of the generation
(`q.later`): a game type dip orders the levels in sequence, at random,
or by tiers (the game's own doMixSEQ, doMixRND, doMixTIE, run in the
shim), from the map, or from the dips' level and segment; clip ends are
tested "at or past"; the states after a win, a death, and the finish are
numbered differently (the extended play waits in branch07, an extra
death clip ends in branch08, a "get up" clip in branch09, the finish
counts from branch01); a rewind resumes inline; the intro holds its
command screen twelve seconds and its rankings eight, shows a filler
after them, has no quit combination, and ends its secret in RIGHT; a
CHOOSE move (only the TV Show has any) is a choice among three, R.doChoose;
the offsets are copied under short names every title-clip frame
(saveOffset); a difficulty penalty (iPenal) shifts the windows; and the
finish screen is the game's own 900, which the loop uses when the game
names one. The shim answers MYDIR with the game's own value when the
game took it from the script's path (the files sit in Script/ under the
game's folder), and the converter finds the dips in Cfg/ beside Script/,
through the cfgReadPath its service.singe uses; forgePorts takes such a
game's whole folder, and compare.sh puts its Cfg/ back from there.
Dragon Trainer then agreed for six thousand frames and the full run, Puss
in Boots, Conan, and the TV Show for six thousand; on the way, the port
makes the game's save and config functions do nothing (setupLevel
autosaves, through lfs and io the shim now stubs), and gives the map
screen (the game's own doLevelSelect, which Samurai Jack and Daitarn 3
start from by their game type) the sprite table its drawing indexes.
**The drive's coverage (2026-09-16, the owner asked).** The drive had
answered every move right but every fifth, which it answered wrong (a
stray input; a wrong pair; a shot outside the zone), so the wrong-move
death, the life lost, the rewind or the segment restart, the continue,
the game over, and the board were walked, but not a missed window, a
wrong choice, or a broken mash. Now every seventh move (not a fifth) is
left unanswered, a choice or time-stop on a fifth move takes a wrong
option, a mash on a fifth gets a stray direction, and a move met again
after a death is answered right (the later copies rewind to it, and a
retry answered wrong again had been ending games at five deaths). Every
port gets a sweep with the wider drive: testScripts/ports/sweep.sh runs
every pair, two at a time. The first sweep (2026-09-16 evening, six
thousand frames): 51 of 52 pairs agree -- the ActionMax five, the
nineteen KarisFramework descriptions, the six map-mode games, and the
twenty-two Hypseus titles -- and the one that did not (Daitarn 3)
produced no port trace at all: compare.sh now refreshes Forge into its
run directory at every start, and two pairs sharing one directory had
one pull Forge from under the other. sweep.sh gives each pair a
directory of its own; Daitarn 3, rerun, agrees, as does hq-timegal's
full-length run (a game over ends it after 6,336 frames). singePorts is
reassembled with the seven new titles (52 ports), launched, and the
release repacked.
**hq-timegal (2026-09-16).** The `timegal` behaviour follows the rdg one
in Author.singe: T.doLevel (checkpoints, the tally, the resurrect and
death clips by frame number, as the game hardcodes them), T.doTimeStop
(three options, shuffled on their first drawing as drawOptions does),
T.doIntro (the two intro clips by dip, the filler, the credits clip, the
rankings still, the half-minute "press start" on a coin), T.startGame
(the three level orders through the game's own doMixLevels functions),
T.addPoints (the 1-up quota), the continue, the game over, and the board
with the name unentered; T.setupClip is the game's toolbox one (no skip
when the disc is a frame short of the clip). chooseLevel, setupLevel
(with its mirror draw), fetchIntroVideo, doFillerFrame, and the board
functions are the game's own. The converter finds hq-globals.singe
before the Singe 1 globals.singe the title keeps beside it and reads the
dips and board from hq-timegal.cfg through hq-service.singe; the views
read Time Gal's row (clip start, window, clip end, death clip, kind) and
its options at branch03. Its first comparison is running. It agreed for six thousand frames on
the first run that reached play (levels, deaths, a time-stop, the second
level) and moved to ported/hypseus with the six Italian titles (Samurai
Jack and Daitarn 3 agreed once the map screen had its sprite table);
their launchers went with them.
**Super Don Quixote, read (2026-09-16).** The one hybrid: map-mode data
(createLevelNN, setupLevelNN, SetupFramesLevel, getIntroClip,
setupDeathClip, NextLevel, doLevelSelect, stage and scene tables, iCurPos
and iSegPointer, a level order by game type) under a Karis 3.31c level
loop (branch11 the right state, branch02 a wrong one with the hints dip's
frame in branch03, HOLD through checkHold with a length by difficulty,
LETGO, the three mashes with unMash and a counter by difficulty, DOUBLE,
the timer-tested pairs, PATH and YESNO with the path table's jumps
(iPath, iPathAend, iPathAjmp) and deaths over 1000, SKIP, a rewind that
skips a LETGO), with its own additions: a difficulty select screen
(doDiffSelect), a clear screen (doClear), trophies (trophy.singe,
doTrophy), a display dip (dip_Res, drawing only), a practice game type
(4: a wrong move costs nothing), a "get ready" clip in branch08 and an
extra death clip in branch07, thirty-one levels, and addons.singe. Its
doLevel is some 680 normalised lines off every Karis copy and the
map-mode ones alike, so it is a loop of its own: the plan is the qte
loop's judging (Q's 3.31c paths) over the rdg loop's data loading and
screens, in a `sdq`-gated section, after the Hypseus goal is closed
(release, docs, memory) -- it is a library title, not a Hypseus one.
**Super Don Quixote, built (2026-09-16).** Its 311 move rows use only the
five plain kinds, so the exotic judging is transcribed but unreached.
The `sdq` behaviour follows the timegal one in Author.singe: S.doLevel
(the game's states, including branch11 the right move, branch02 the
wrong one, the hints frame, the practice game type, the two "get ready"
clips, the rewind that skips a LETGO, the path jumps), S.doChoose,
S.startGame (with the difficulty select), S.doIntro (eight stops),
S.doDiffSelect and S.moveFrameDiff, S.doFinish then S.doClear every frame
of the level's end as the ancestor runs them over one state, S.doContinue,
and the judging helpers under the game's names; its attach is the rdg
one plus the game's own state, the extra clips by the Extravid dip's
table of eight, hints a boolean, the action and movie dips numbers (the
ancestor compares the movie dip with booleans, so neither start holds).
The converter marks the loop `sdq` when a map-mode globals names the
Karis kinds (HOLDUP), reads the two next-level tables out of doLevel's
text as data (`sdq.beaten`, `sdq.unfinished`), and carries the per-level
percent records after the board (`percents`), reading the config the
service opens through cfgReadPath before any MYDIR fallback; R.doHighScore
ends without the two-second hold for it. The port agrees with the
original on its first comparison (82 trace lines: play, wrong moves,
deaths, rewinds). The last library title; moved to ported/ with its
launcher, singePorts reassembled (53 ports).
**hq-timegal, read (2026-09-16).** RDG2010's "Time Gal (Singe Edition)",
the HD remake of the Singe 1 Time Gal: its own compact loop, not the
map-mode one. A move is `{ inputStart, inputEnd, a second pair, deathStart,
deathEnd, kind }` with the kinds UP, DOWN, LEFT, RIGHT, ACTION, and
TIMESTOP; a level's moves come from arrayEasy, arrayNormal, or arrayHard
by difficulty, mirrored by a random draw (an offset added to every frame,
LEFT and RIGHT swapped); play has checkpoints (sections reached, resumed
from after a death), a time-stop choice of three (opt[] with a right one
and a death clip per wrong one, shuffled), a tally with a bonus by deaths,
a 1-up quota, and a level order by tiers (doMixLevels), at random, or
sequential, with chooseLevel picking the next unbeaten; the intro
alternates two clips and a still per filler. About seventeen hundred
lines with the drawing. A fourth behaviour, `timegal`, after the three
map-mode titles agree; the Singe 1 `timegal` monolith in the library is
its ancestor and may share it.
### 14.13 The American Laser Games family (surveyed 2026-09-16)
**What it is.** Eleven Hypseus titles (Mad Dog McCree, Mad Dog II, Crime
Patrol, Drug Wars, Last Bounty Hunter, Space Pirates, Who Shot Johnny Rock
twice, Johnny Rock Noir twice, Mad Dog II Typing) and twelve of the
library's (six originals, six HD), each a hand-written program: 15 to 27
`doLevelXxx` functions of a few hundred lines each, 36,000 to 82,000
lines a title with the hitbox files. Nothing is shared but the shape:
the "skeleton" (doIntro, doContinue, doGameOver, the input handlers)
differs completely between Mad Dog and Crime Patrol, and the library's
HD Mad Dog is 5,000 normalised lines off the Hypseus one. A level's data
is mechanical: `move` rows `{ shootStart, shootEnd, clipStart, clipEnd,
hitmapStart, endFrmRandom }`, a `hitmap` per frame `{ frame, hitboxIndex,
count, bonusIndex, civilianIndex, civilianCount }` over `hitbox`,
`civilian`, and `powerup` rectangle arrays (`GetBankArray` and its kin,
12,000-line files), scaled by `ResHMx`/`ResHMy` to the overlay. A
level's flow is code: states (lvlSetup, lvlRunning, lvlPlayRest,
lvlPauseAction, lvl2ndChance, lvlPlayDeath, lvlUndertaker, lvlPlayAdvice,
...) with hardcoded frame triggers (`currentFrame == offsetlvlBank +
530`), random approaches, "first time" resumes, bullseye windows, a
second-chance delay, and the undertaker's death clips by gender and
lives. Two players share the loop: `p1BUTTON3`/`p2BUTTON3` the
triggers, two pointers, two ammo counts (six, a reload by pointing
offscreen or at the borders by dip, or immediate), two scores, one lives
count; a town select by pointer quadrant; continues; a name entry.
**The design.** The owner's choice (14.10) is a rewrite per game in
Forge's terms, and Forge's rail-shooter model (section 2, "the keyframed
hitbox") was drawn for exactly this: a `hitboxes` track keyed by disc
frame is the hitmap and its rectangles; a `gun` per player with ammo and
a reload gesture; `hit` events; a branching track of windows. So:
1. A converter, `util/forgePortAlg.py`, lifts what is mechanical: the
move rows and the hitbox files into `hitboxes` tracks (bad guys,
civilians, bonuses, each a rectangle keyed by the frame it belongs
to), per level, with the level's disc offset applied.
2. An `alg` behaviour for what every title shares in kind if not in
code: two guns, ammo and reload by dip, scores, lives, credits,
continues, the death clip chosen by what was shot and how many lives
remain, the level select, the intro, the name entry -- written once,
with the per-title numbers (frames, sounds, clip lists) in the
description.
3. Each level's flow rewritten as data: a `windows` list per level
(`shoot` windows naming their hitmap range and what a hit skips to;
`bonus` windows; `resume` points for a second visit; `trigger`
frames that jump the move; `random` approaches; an `advice` clip),
read by hand from its doLevel and written into the description.
This is the per-game work, a level at a time, 15 to 27 per title.
4. A drive that shoots: the harness views hand the drive the current
window's first hitbox (the pointer to its centre, the trigger on the
window's third frame), a miss well outside it on every fifth, a
civilian on every seventh where the frame has one, no shot on some,
and the reload gesture when ammo is out; compare.sh gets an `alg`
family for the Hypseus layout and the library one.
The level vocabulary, read from Mad Dog's nineteen: a level is a run of
`move` rows played in order; each row a `shoot` window (frameShootStart
to frameShootEnd) over a hitmap range, a hit scoring SCORE_BADGUY and
skipping to the window's end, the window's end going to a second chance
(a pause of iDelay seconds still taking a shot) and then the row's death
clip (frameDeathStart to frameDeathEnd), a life gone and the undertaker's
clip by what happened (NORMAL, SHOTGUY, SHOTWOMAN, NOGENDER, GOODGUY, or
by lives left), or to a rest (played to endFrmRandom, then a three-second
pause before the next row's window); a civilian hit anywhere is a life
and the undertaker; bullseye windows by frame with a bonus rectangle
(ammo to twelve); play-clip states with hardcoded frames and skips; a
random draw for an approach or a mirror; first-visit resumes; advice
and hint stills; and the level's end deciding the map, the lives-left
still, the maze, or the showdown. Mad Dog II and the others add their
own (the typing edition types). One thing the survey settled against
the converter lifting the data: a level's rows are shuffled at setup by
a random draw (setupLevelBank's fRndPopOrder), so the port must draw at
play as the map-mode ports do -- the game's own setupLevel and hitbox
files run through the shim, from a vocabulary behaviour beside the game
(as the ActionMax light sensor is), which judges shots against the live
hitmap and answers the description's rules with conditions and actions
of its own (shotHit, shotCivilian, bonusHit, undertaker); the level
flows stay rules in the description, editable, over Forge's frameReached,
timer, discTo, and the rest. Author gained what a two-gun game needs:
a press carries its device, so the pressed event names the player and
only that player's gun fires, and a hitbox track with step = true keeps
a box only on the frames it keys. The pilot is Mad Dog McCree (Hypseus,
19 levels, the smallest of the HD set): the converter and the behaviour built against it, its levels
rewritten one by one against the comparison, then the other ten and the
library's twelve, each its own descriptions over the shared behaviour.
**The pilot, begun (2026-09-16, evening).** The converter's `alg` mode
runs the title's script and its files under the stubs (its hitbox tables,
its board, its service) and reads the dips from the file its service opens
through pcall; the description names `Vocabulary.singe` beside it. The
vocabulary (`~/claude/singetest/hypseus/maddog-hd/singe/maddog-hd/`) is
Mad Dog's loop over the game's own files loaded through the shim
(authorShim and authorRunScript are the public doors): the start, the
player select, the attract loop, the town select, the tutorial and the
first level, the continue, the lives-left still, the game over, and the
board so far, with the two players' twin state reached by number so each
piece is written once. The harness: algDrive.singe (one gun joins by
START, a coin and START begin the game, shots at the first hitbox of the
window's frame three frames in, a miss on every fifth move, none on every
seventh, a reload by pointing offscreen, the first unbeaten quadrant at
the town select), algOriginal.singe and algPort.singe (the pointer put
where the game's onMouseMoved would compute it, presses with their device,
both drivers pinning os.time for the game's seed), and compare.sh's `alg`
family. Two engine changes on the way: a mouse or gun switch now reaches
onInputPressed with its device index, as Hypseus passes it (the original
needs it to tell its players apart), and a sprite scaled to nothing draws
as a pixel instead of ending the engine (the original's bullet shrinks to
zero after a hit, which Hypseus let pass). The port agreed with the
original through the player select, the attract loop, the tutorial, and
the first level, and then level by level as each was transcribed: the
Corral, the showdown, the Saloon, the Sheriff, the Bank (271 trace lines
over twenty thousand frames agree), then the signpost, the fuse, the
mine, the bottles, the maze and its signs, the pond, the plateau, the
cliff, the canyon, the hideout, the house, and Mad Dog himself, with the
tie's countdown; the vocabulary is some three thousand lines against
the original's forty-four thousand, the two players' twin state reached
by number and the shared shapes (a shot judged against the hitmap, the
bonus, and the civilians; the death, undertaker, and end states; the
wilderness levels' one shape) written once. The drive shoots the
signpost's sign, the mine's lantern and item, and the maze's signs from
targets the views hand it. What is not yet transcribed: the two-player
tie shootout and the second player's board (a one-gun run never reaches
them), the service menu, and the drawing (the port draws nothing yet).
A forty-thousand-frame comparison with the first eleven levels in
agreed (325 trace lines), and one with every level in agreed the same
(the drive's deliberate misses keep a run in the four town levels, the
showdown, the continue, the board, and the game over); the drive gained
PORT_PERFECT=1, no wrong or missed shot, and a sixty-thousand-frame
perfect run agreed too (567 trace lines): the town levels, the
signpost, the fuse, the mine, the bottles, the maze and its signs, a
showdown, the continue, the board, and the game over; the hideout, the
house, and Mad Dog himself lie past where the drive's lives ran out.
One trap on the way, worth knowing for the whole family: the original
writes its board into the .cfg beside its script at every board entry,
so harness runs had changed the shipped file and a reconversion had
carried a run's scores into the description; the file is restored,
compare.sh's alg family keeps and puts back the .cfg files beside the
script, and compare.sh's per-side timeout is an hour. Mad Dog moved to
ported/hypseus, singePorts reassembled (54 ports), the release repacked. The
tie shootout and the second player's board are transcribed too, untried
by a one-gun run. The library's single-player Mad Dog (8,600 lines) is
the same disc with the same offsets but an older, simpler flow per level
-- half to all of each level's lines differ even with the second
player's stripped -- so it is a transcription of its own over the same
shapes, as each of the other twenty-one titles will be.
**Mad Dog II: The Lost Gold (2026-09-17, small hours).** The second
title, the same way: the converter's `alg` mode read its script (9,200
lines and its globals, hitbox, and setup files) into a description with
its dips and board, and a vocabulary beside it
(`~/claude/singetest/hypseus/maddog2-hd/singe/maddog2-hd/Vocabulary.singe`,
2,700 lines) transcribes its loop over the game's own files: the start,
the player select, the attract loop, the guide menu (three panels, the
first unfinished guide when the twenty seconds run out), the first level
with its bonus bottles and the monk's turn, the three guides' levels and
finales (Beaver's four sections and random item, Bonnie's woman who must
not be shot and her gun emptied at the second section, the Professor's
two levels with his own second hit test), the two item levels with the
guide's clip, the prospector's two (his skull from four at random), the
two town levels, Mad Dog himself with the ending earned by game type and
continues, the showdown, the continue, the lives-left still, the game
over, both boards, and the tie-break. Its shapes differ from Mad Dog's
enough to be its own transcription (a `finishLevel` end that goes back
to the level, or the menu in game type 1; a draw lost that still runs
the hit test for the record; three kinds of undertaker clip by whom the
civilian was) but the vocabulary's helpers are the same in kind, and
three of the game's habits are kept as they are because the trace must
agree: it indexes its move rows by `hitmapStart`, a name it never
defines (its hit tests take the range from the row themselves), it
spares the attract loop's ammo by comparing with `lvlIntro`, also never
defined, and its second item test names `thisItem`, likewise, so any of
the three items ends the level. The harness views learned its tables
(the hitmap range is `hitboxStart`, the move row has no shoot window so
the move's own frames stand in, its levels are 1 to 15 and 104) and the
drive shoots its guide panels. The first comparison (the drive's
misses, fifteen thousand frames) agreed to the line: the player select,
the attract loop, the first level, the menu, Beaver's level, the
continue, the board, and the game over. With every level in, the views
handing the drive the item and the skull to shoot and the drive putting
a coin in before it takes the continue, a sixty-thousand-frame perfect
run agreed (541 trace lines: the first level, the menu, Beaver's level
and finale, a showdown, both item levels, the prospector's two, both
town levels -- where the drive, shooting each move's first box, shoots
the women and runs out of lives -- the continue, the board, and the game
over) and a forty-thousand-frame run with the drive's misses agreed
too (475 lines). Mad Dog himself lies past where the drive's lives run
out; the tie-break and the second player's board are transcribed but
untried by a one-gun run.
**Crime Patrol and Drug Wars (2026-09-17, small hours).** The third and
fourth titles are a template of their own (the same authors' port, the
one program twice with different frames): a start and player select
like Mad Dog II's, an attract loop, four mission menus of three or four
panels each (shot by the game's own `panelHit` over line arrays, the
first open panel when the clip runs out, a closing clip when a mission
is done), twenty levels (Crime Patrol) or seventeen and a practice range
(Drug Wars), a lives-left still, a continue, both boards, and a
tie-break. A level here holds the disc at a move's last frame for the
difficulty's delay instead of pausing at it, a death is a mission's
"nag" clip chosen by the game's own function (which takes the life), a
good guy passes by himself and shooting him is the friendly-killed nag
or, at a few moves, a silent loss of the life, and most levels are the
one shape with a table of what differs (an opening the trigger skips, a
stretch it skips to a frame, moves whose ends play out as clips, the
civilian rule, the bystander rule, a section to resume at, a closing
clip); the ones that are not (Crime Patrol's practice range with its
five-target still, its strip club, its hangar and nuclear plant runs;
Drug Wars's boat, night vision, town, and final level with its last
stand) are written out. Crime Patrol's vocabulary is 2,600 lines
against the original's 8,700 and its hitbox files, Drug Wars's 1,900
against 8,700. Kept as the games have them: `lvlIntro` and, in Drug
Wars's South America menu, `MENU_DELTA` and the hangar levels, names
from Crime Patrol it never defines; the lives-left still's four
resumable levels that neither game has. The shim gained stubs for
sprite frame loads and colour keys (both games reset them at load).
The harness views learned the menus' panels, Crime Patrol's practice
still (in the game's own pointer coordinates) and cutouts, and Drug
Wars's last stand. Crime Patrol agreed for fifteen thousand frames with
the drive's misses (365 trace lines), sixty thousand of perfect play
(545), and forty thousand with misses (499): the practice range, the
store, the gang, and the warehouse, the continue, the board, and the
game over. Drug Wars agreed for fifteen thousand (333), sixty thousand
perfect (458), and forty thousand with misses (380): the Sierra intro
and menu, the practice range, the bar, the continue, the board, and the
game over. Neither run got past its first mission: the drive shot each
move's first box, and in these games a move can be a good guy's, whose
shooting is a death, so the views now hand the drive no window on a
good guy's move. Two of the game's habits found on the way: Drug Wars
names no `levelIntro` at all (its attract loop runs with the level nil,
and the port dispatches on that), and its practice range counts as done
however it ended.
Both originals then moved into `ported/hypseus/`, `forgePorts.py` built
fifty-seven ports, and the launch check ran them all headless (one run,
38AmbushAlley's, lost its X server mid-way and was rerun clean).
**The Last Bounty Hunter (2026-09-17).** The fifth title is a program
of its own again: the army base first, then a signpost still (the
practice range, the wanted-poster menu, or a bonus to shoot), a menu of
four outlaws whose panels read at large, captured, or dead, twenty-three
outlaw levels in four chains (Cactus Kid's six, Dan's three, Harry's
five, Loco's four with a coin-toss alternate), each outlaw's own move
where a hit kills him and a civilian hit captures him alive, an advice
clip once a game after a kill, a bonus level once a game by the game's
own clock, a quick-draw showdown drawn at random after a menu choice
(the first always), two final levels where the outlaws taken alive are
met again (Harry's bribe, the Cactus Kid's showdown with the gun
holstered), a shotgun of eighteen shells from bonus hits, a kill streak
with its own bonus, and both boards with a girl's clip when every outlaw
was taken alive. The vocabulary
(`~/claude/singetest/hypseus/lbh-hd/singe/lbh-hd/Vocabulary.singe`,
2,700 lines against the original's 9,700) is the common shape once more
with the hooks the levels need (the civilian rule while the disc is
held, what a shot between the moves does, the outlaw's move, the level's
own draw, the clip after a clip, the closing clip, the next level or a
replay), and the practice range, the signs, the menu, the bonus level,
the showdown, and the second final level written out; the game's own
reload, scoring, streak, and bonus functions are reached by player.
Its first comparison found the original itself dying at the army base's
board: the game selects a font it never loaded (`fontscore1` for
`fontScore1`), which Hypseus lets pass and Singe ended the script on;
fontSelect now keeps the current font and warns. The rerun then showed
the port a thousand points short after a miss: the game pays its kill
streak from the pointer's splat animation in the frame loop (a hit
starts the animation, and each of its frames that is not a hit's calls
the miss handler), which the port had left with the drawing; the
vocabulary now carries that bookkeeping (`blink` on a shot, `splat` for
each gun after the level). One more: the Cactus Kid's first level
names its intro clip's state differently from Harry's and Loco's
(`lvlPlayClip1`, not `lvlPlayClip3`), which the traces compare, so the
level table now names the state.
The Last Bounty Hunter then agreed for fifteen thousand frames with the
drive's misses (407 trace lines: the army base, the signs, the menu,
Cactus Kid's and Loco's levels, Harry's first, the continue).
**Space Pirates (2026-09-17).** The sixth title: an asteroid range,
three doors (the second with a band of colour to shoot and the moves
swapped when the other band is shot first, the third with the commander
saved by a shot at Ursula's colour, then the crystal order and the
cannon's whereabouts), a planet menu of four quadrants, four planets in
eleven levels (Fellina's skull and arrow puzzles, the Mountain, the
Reaper's counted laughs, the Scrapyard's move not to shoot first) with a
crystal shown after each planet's first win and the cannon practice on
whichever planet holds it, a crystal build against a ten-second clock,
a ship battle of cannon shots judged on the next frame, Tallon, and the
continue, the lives-left still, both boards, and the tie-break. The
vocabulary
(`~/claude/singetest/hypseus/spacepirates-hd/singe/spacepirates-hd/Vocabulary.singe`,
2,700 lines against the original's 11,500 and its hitbox files) is the common shape with the
hooks these levels need (an intro clip or a frame to wait for, the
level's own clip advance, a hit's own skip, the civilian rule, the
closing, the level's own death clip, states of its own), a second shape
for the object ranges (Fellina's, the Mountain's, and the Reaper's
second halves), and the doors, Fellina's puzzles, the Reaper's laughs,
the Scrapyard, the cannon practice, the ship battle, Tallon, and the
crystal build written out; the crystal after a win is a state of the
level (the game's `doShowCrystal`, which also runs the cannon practice
from inside it). The game's own setups, hit tests (`shooterHit`,
`civilianHit`, `bandHit`, `ursulaHit`, `fellinaHit`, `socketHit`), death
clips by kind, laugh counts, crystal order, and menu clips run through
the shim. Two habits kept: a press on an empty gun is left standing on
the asteroid and object ranges (the flag clears only with ammo), and the
Reaper's first level does nothing at all when its second section is
reached (the original stays in its setup). The harness views learned
the planet menu's quadrants (pointer coordinates scaled by the game's
`UnX`/`UnY`), the band within its frames, Ursula within her colour's
frames, the skull and the arrow, and the sockets in the order shown.
Space Pirates agreed for fifteen thousand frames with the drive's misses
(397 trace lines: the range, the first two doors with deaths, the
continue, the board, the game over).
Its perfect run then died at the second door's fourth move every time:
the drive aims at the first hitbox of the frame it sees, but the press
it makes is judged on the next frame, and this target moves enough that
the middle of one frame's box lies outside every box of the next. The
views now check that, and aim at the next frame's first box when the
current one's middle would miss it (the slow targets of the earlier
titles are aimed at as before). The same run found a hole in The Last
Bounty Hunter's port (the advice clip's end hook called without the
game's state, an error the misses run never reached) and a port view
that indexed the game's band and line tables by unqualified names, so
the port never shot the band the original hit.
The rerun then showed the original itself stuck at the second door's
colour hint: the game changes state on the clip's last frame and
expects to read that same frame once more in the new state, which a
game paced for an overlay faster than its disc can, and which the
harness's deterministic clock never allowed, since `--deterministic`
stepped the disc one video frame per engine frame whatever its step.
The engine's deterministic mode now steps the disc by the virtual clock
like everything else, and a second fault came out with it: the frame
loop's overlay throttle compared the clock strictly, and on a virtual
clock that moves exactly one step an iteration that ran the script
every other iteration, a disc frame apart. With both put right each
disc frame stays on for the two or three engine frames its time is
worth at the default step, which is what real play does (a probe of the
disc's frame against the clock's ticks confirmed it); the manual and
the changelog say so, and every comparison below this point ran on that
timing. The same perfect run reached Harry's
last level in The Last Bounty Hunter and found its finished step missing
from the port's table (the original goes back to the menu, the port
replayed the level).
On the corrected timing every port was run again with the drive's
misses for fifteen thousand engine frames, and all agreed: Mad Dog
McCree (174 trace lines), Mad Dog II (114), Crime Patrol (268), Drug
Wars (198), The Last Bounty Hunter (183), Space Pirates (241), and both
Johnny Rocks (106 and 111); The Last Bounty Hunter's perfect run agreed
for a hundred and thirty thousand (483).
Space Pirates's perfect run then walked the whole game and agreed for a
hundred and thirty thousand (736 trace lines: the range, the three
doors, Fellina's two levels with the puzzles, the Reaper's three with
the laughs, the Scrapyard's first, the cannon practice, the Mountain's
two, the crystal build, the ship battle, Tallon, the board, the game
over).
**Who Shot Johnny Rock? (2026-09-17).** The seventh title, the noir
multiplayer edition, is the detective game: money in place of lives
(the doctor's bill at every death, the undertaker when it runs out, and
more bought with a credit or at the store), Johnny's office first, then
a city map with a hint before it (the game's own rules for which),
four places to clear for their clues (the casino, the garage, the pool
hall, the warehouse's three parts), a store, random encounters on the
way to a place (one of fourteen scenes), bonus puzzles opened by hints
and expiring after a visit (roulette, cards, the pool balls still and
rolling, the license plates, the elevator panel, the cans, each paying
on the lucky number's target), the mansion in four parts (the moves,
the four clue items in the order found, the bomb, the safe's dial by
the combination), the killer's reveal, and a finale of four random
scenes and the killer's own, then the boards and the tie-break. The
guns fire from the frame loop against a real-time gun delay (`os.clock`
and ninety milliseconds), the trigger flag standing until the release.
The vocabulary
(`~/claude/singetest/hypseus/wsjr-hd/singe/wsjr-hd/Vocabulary.singe`,
2,500 lines against the original's 13,300 and its hitbox files) is one
common shape with two variants (a hit moves on at once, or its clip
plays out with the civilian test live under it) and hooks for the
intro, the ending clip's skip, the clue, the closing, and the level's
own hit and running rules; the office, the map with its hint, clue,
and kill-clip sub-machines, the store, the mansion's items and safe,
the random scenes (shared by the encounters and the finale's four), the
killer's scene, the seven puzzles (two shapes: moves, and panels on a
still), the continue, the boards, and the tie-break are written out.
The game's own setups (fourteen random scenes included), hit tests,
nags, undertakers, hints' clips, safe digits, and boards run through the
shim. Its first comparison diverged at the casino's move order: every
level's setup spins the shot mark's sprite by a random angle, a draw
the picture made that the generator counts, and the port now draws it
too. The harness views learned the city map (the killer's place once
revealed, the mansion once the clues are had, else the first place
open), the clue items in the game's second scale, and the safe's dial
when it shows the next digit; money stands in for lives in the trace.
The second run agreed apart from the hints' timing, and that was the
description's doing: the game's config reader keeps the showdown dip a
number, and the game tests it against `false`, which a 0 never is, so
its hints play with the dip off; the port had made it a boolean. Who
Shot Johnny Rock then agreed for fifteen thousand frames with the
drive's misses (203 trace lines: the office, the map with its hints,
the casino three times over, the pool hall, the warehouse).
**Who Shot Johnny Rock? (Singe edition, 2026-09-17).** The eighth
title is the older one-player cut of the seventh, and its vocabulary
(`~/claude/singetest/hypseus/johnnyrocknoir/singe/johnnyrocknoir/Vocabulary.singe`,
2,100 lines against the original's 9,900) is the multiplayer one with
the twin state gone and every difference the diff of the two scripts
showed: no player select (the disc's wake goes straight to the attract
loop, which the drive now takes as joined), a move's end passed rather
than reached (the disc put back on its last frame for the draw), the
casino's, the mansion's, and the encounters' draws without a civilian
test, the office's civilian at the draw passing the move by, the move
orders drawn from fixed tables rather than shuffled, the one encounter
with two bad guys at once (the second reached through the civilian
test, a moment's pause for both, a death by whoever is left), other
clip frames throughout, a single board that waits in a state the game
names but never defines (and matches on all the same), and a gun of its
own: a held trigger rolls for a jam once a frame (one chance in three
hundred and ninety-one, a draw the generator counts) and otherwise arms
a delay of its own clock, the puzzles' shots counting only between
shots. The dips are typed as its config reader types them (the
undertaker and the showdown booleans here).
It agreed at its first comparison, fifteen thousand frames with the
drive's misses (244 trace lines: the office, the map, the casino, two
random encounters, the pool hall).
**Mad Dog II, the typing edition (2026-09-17).** The ninth title is
Mad Dog II played on a keyboard: every move a word (a letter on the
train, a sum in the second item level, a phrase in town, six or
eighteen letters sprinkled over the picture at the showdown and at Mad
Dog) typed as it runs, the disc held at the move's end for the
difficulty's seconds, the word untyped there a death, the trails ending
in a thirty-second rush at three times the disc's speed with a tally
after, and a title screen, a guide menu, and a board driven by the
return key. The vocabulary
(`~/claude/singetest/hypseus/typing-md2/singe/typing-md2/Vocabulary.singe`,
2,200 lines against the original's 19,000, most of them its word lists)
is one typed shape with hooks (the word's field, a phrase or a moving
letter, good guys whose words cost or kill, the clip's end, the draw
missed, the level's own clips), a second for the three rushes, a third
for the two sprinkled levels, and the office (its own three clips and
sections) written out; the game's own setups with their word draws,
its word and phrase lists, the train's hit tables, the undertaker's
clips by the lives left, and the board run through the shim. The keys
reach the port as the runtime hands them over, the keysym as a switch
(the return and backspace keys, which the runtime keeps apart, named
back from their scancodes), and the game reads one at a time from
`iKey` as its own script does. The Johnny Rocks' perfect runs (the
casino, the pool hall, the warehouse, the garage, the mansion's four
parts, the finale) turned up two more faults: a level's unfinished
hook called without the game's state in four of the vocabularies (an
error the misses runs never reached), and a drift in the originals'
own config files, which the games write at every board and which a
comparison killed part-way never put back, so the multiplayer edition's
safe combination came from a later entry of the pool than the
description's; the files were restored from the descriptions, and
compare.sh now puts them back however it ends. With both mended the
two Johnny Rocks agreed for a hundred and thirty thousand frames of
perfect play (848 and 715 trace lines: the office, the map, the
casino, the pool hall, the warehouse's three parts, random encounters,
the garage, the mansion's four parts, and the finale's random scenes,
where the drive's luck runs out). The harness learned to
type: the views name the key the game waits for (the next letter of the word, a
sprinkled letter not yet typed, the magic word's, the return key at the
title, the menu, and the board), the drive releases it (a wrong letter
first on every fifth move and nothing at all on every seventh with
misses on), and the converter recognises the edition by its globals
file and reads its dips from the config its service file names.
Its first comparisons found the views calling Mad Dog II's hitbox
scaler at the guide menu (a game with no hitboxes has none), the
converter keeping a frame rate the game never names (a stub function,
written into the description), the drive waiting for a key slot the
game starts at zero rather than at none, and, once the office was
typed, the port's first word running longer than the original's: the
picture flickers each letter already typed in a colour drawn three
times a frame, draws the generator counts and the words are drawn
from the same generator, so the port draws them too.
With those mended the typing edition agreed for fifteen thousand
frames with the drive's misses (263 trace lines: the title, the
office, the continue), and its perfect run typed its way through the
office, the guide menu, Beaver's trail, and the rush before the views'
item branch (Mad Dog II's, another scaler call) ended both runs alike.
With that branch kept to the games that scale, the perfect run agreed
for a hundred and thirty thousand frames (the office, the menu,
Beaver's trail and rush, both item levels, the prospector's two, the
town, the continue, and the board, where the drive's return key had
seeked the name's clip back every frame until the run ended -- pressed
once now).
All five then closed together: their originals into `ported/hypseus/`,
`forgePorts.py` built sixty-two ports, and the launch check ran them
all headless. The typing edition alone failed it: its script names
its files by their place in the Hypseus tree (`singe/typing-md2/...`),
which the shim's file resolution only stripped when the name began
with the port's own directory; it now strips the tree's prefix too,
and the ports were rebuilt and launched again.
**The two Johnny Rock copies left (2026-09-17).** The library's
`johnnyrocknoir__hypseus_singe_johnnyrocknoir_RGB` is the one-player
edition's script to the line (only the launcher's header differs), a
video-only alternate that stays in `hypseus/` unported. `WSJR_HD` is
the same one-player edition in the HD layout (its files under Script/,
its config under Cfg/, its hitbox scale 360 wide rather than 320), and
every difference from `johnnyrocknoir` is drawing or paths, so it is
ported with the one-player vocabulary copied beside it unchanged; the
converter learned to load a title's files from a Script/ folder and to
find its service file there. It agreed at once, both with the drive's
misses for fifteen thousand frames (111 trace lines) and for a hundred
and thirty thousand of perfect play (758: to the finale, as its
twin), and closed with the others -- once `forgePorts.py` learned to
keep a hand-written title's Fonts folder, which it leaves out of every
framework title's port (those draw their own text) and which this one
loads its board's face from. With that, sixty-three ports are built and
the launch check runs every one of them clean. That is every hand-written
American Laser Games title of the Hypseus library ported; the
library's own twelve remain.
**The library's own editions (2026-09-21).** The fourteen left are the
older one-player Singe editions the library keeps at its root (six
titles and the HD copy of each: Who Shot Johnny Rock?, Space Pirates,
Mad Dog McCree, Mad Dog II, The Last Bounty Hunter, Crime Patrol, Drug
Wars), laid out like the map-mode copies (the script and its files in
`Script/`, the config in `Cfg/`, the video and its framefile in
`Video/`), and each is a transcription of its own again: the HD Mad
Dog is not the Hypseus one, and the one-player cut of every game is an
older, plainer loop than its two-player edition (no player select, the
pointer raw, a single trigger flag the levels spend, `iBullets > 0`
tests, a level's rest and pause states with their own frame rules).
The two Johnny Rocks went first, with the vocabulary the Hypseus
one-player edition already had, and agreed at once (110 trace lines
with the drive's misses, 757 of perfect play). The other ten each got
a `Script/Vocabulary.singe` written from the game's own loop (the HD
copy the same file, since its differences are scale and paths: Space
Pirates's common level and object range shapes with a `clipDone` step
and the game's own `doShowCrystal`, its START mirroring the seed the
game draws from the clock; Mad Dog's wilderness shape with the
per-level reset on a hit, its showdown by flag, its mute delay, and a
board that waits in the game's own state; Mad Dog II's finale shape
for Beaver and Bonnie, the menu that leaves the trigger standing, the
board that waits in a state the game names as a frame number, and
game type 2 keeping the panels; The Last Bounty Hunter's `bonusTry`
over boxes and ranges and a level table of some twenty hooks, its
showdown by flag; Crime Patrol's practice range of six cutouts, its
menus, and the common shape with the run-up the trigger skips; Drug
Wars's the same over its own levels, the final level without the last
stand the two-player edition added). Crime Patrol's and Drug Wars's
HD copies are not the same program as their originals, so their
vocabularies tell the editions apart by the resolution the HD globals
take from the disc: the HD copies take the bullet on the press (a
free one first where a level grants it) and reload by the second
button or, on the trigger's release, by the dip with the borders
measured from the resolution, start at the part their dip names, run
a different attract loop (frames of their own on timers), floor the
score at the board, and skip to a move's clip only when the disc is
not there already; the library editions take the bullet on the
release, reload by the second button alone, and Crime Patrol's
alternates two cuts of its story by a draw at start. What the family
needed on the way: the engine hooks a bare `os.time()` under
`--deterministic` (Space Pirates seeds its generator from it when a
game starts, so the two sides drew different sequences); the shim's
`io` reads a real file (Mad Dog and Mad Dog II read their config at
load, and a stub that answered nothing ended the load at `setShootDelay`);
the converter stubs `require("lfs")`, looks for a config under `Cfg/`
beside `Script/`, and sets `MYDIR` to the title's folder; the
assembler writes a `games.dat` entry from `Video/*.mp4` when no
launcher names a title (the plain Space Pirates has none); the harness
views scale the hitmap's coordinates only where the game does (the
one-player editions aim in the hitmap's own), aim at Ursula from the
game's own numbers before its hit test builds her table, and report
two things the drive needed: the press of these editions takes the
bullet before the level judges the shot, so the last one is never
judged and the views name it a `spare` the drive keeps back (a rule
the one-player Crime Patrol and Drug Wars turn off, since theirs judge
first and take the bullet on the release), and `reloadSwitch` names
what reloads (the second button for those two). Two faults of the
ports' own: The Last Bounty Hunter's shared clip state ran before a
level's own states, and Dan's third level and the first final keep a
clip state past the last move (the level's own states now run first);
and the scaling helper's first draft called itself where the game
scales, which spun both HD Crime Patrols for an hour before the hook
the view now offers (`PORT_HOOK=1` names the line every ten million
instructions) found it. One false alarm: Mad Dog's first perfect run
diverged at the Corral on an engine built before the clock hook, and
agreed to the line once rebuilt. Results, each with the drive's
misses for fifteen thousand frames and perfect play for a hundred and
thirty thousand: Space Pirates 235 and 763 (the whole game: the
range, the doors, every planet, the crystal build, the ship battle,
Tallon, the board -- once the views aimed at Ursula, whose table the
one-player edition builds only inside its hit test; the first run had
ended at the third door for want of that shot); Mad Dog McCree 162 and the whole game (the
town levels, the signpost, the fuse, the mine, the bottles, the maze,
the hideout, the house, Mad Dog himself, and the board); Mad Dog II
122 and 498 (every guide, both item levels, the prospector, the town,
Mad Dog, the board); The Last Bounty Hunter 198 and 734 (all four
outlaws' chains, both finals, the board); Crime Patrol 302 and 1,208
(all nineteen levels to the board); Drug Wars 288 and 1,080 (every
mission to the base, then night vision, where the drive's lives run
out); and the two HD copies that are programs of their own, Crime
Patrol HD 249 and 1,031 (the whole game) and Drug Wars HD 254 and 929
(the same path as its original). The other HD copies, with the same
vocabularies as their originals, agreed the same way: Space Pirates HD
235 and 763, Mad Dog HD 162 and the whole game, Mad Dog II HD 123 and
498, The Last Bounty Hunter HD 189 with the drive's misses (its scale
changes which shots land, so the count differs from its original's) and
753 of perfect play (every chain, both finals, the board).
All twelve then closed: their folders and launchers into `ported/`,
`forgePorts.py` built seventy-seven ports (a title with no launcher of
its own, the plain Space Pirates, gets a `games.dat` entry from its
video), the launch check ran every one clean one at a time (the owner
asked for a single engine from here on), and the release was repacked.
With that every American Laser Games title in the library is ported.
### 14.14 The one-offs (2026-09-21)
Four bespoke scripts remain in the library's root after the families:
Adventures in Videoland: Rollercoaster, Hologram Time Traveler, LINEA
(the MazescaterFramework's one game), and Platoon (an American Laser
Games shape by the same hand as the Singe 2 editions), each ported by
hand, one at a time; BatMaker writes launchers and is not a game (14.2),
and the menu is the engine's own.
**Rollercoaster (2026-09-21).** David Lubar's 1982 text adventure over
the laserdisc, in Scott Duensing's Singe port of Scott Lawrence's web
one: eighteen rooms with exits, a name and description, and a Pioneer
command each (a frame to park on as a still shipped as a picture, or a
clip to play to its end), twelve objects and three pieces of furniture
by location, a two-word parser of some thirty rules tried in order
(several may apply to one line, and `GO` and `READ TICKET` re-enter it),
five rooms with a scene of their own (the restaurant's meal, the
dancer's tent and the bear, the top of the coaster, the guard and the
uniform, the dark path), a prize to name, a question of where to put a
thing, a bomb on a hundred and fifty turns, a play-again, and the win.
The port keeps the split the plan drew for the family: the converter's
`videoland` mode (told by the script's own state names) runs the script
under the stubs and writes the rooms, objects, furniture, directions,
and the turn count into the description as data, and `Vocabulary.singe`
beside it carries what the description cannot say -- a `console` look
(the Apple II screen of lines the game has printed, word-wrapped as the
original wraps them by the font's own metrics, the prompt with its
blinking cursor while the keyboard is on, and the still the disc is
parked on) and the `videoland` behaviour, the game's states transcribed
over the description's tables. The parser layer (14.7) was not used:
the game reads the whole keyboard itself (a letter or a space into a
line of forty, return to take it, backspace, and any key at all where
it waits for one), drops what is typed while a clip plays, and splits a
line at its first space with the noun echoed in its answers, so the
behaviour hears the keys as the runtime hands them over (a keysym on
the switch channel, the return and backspace keys by their scancodes)
and keeps the game's own rules. The harness gained a `videoland`
family (compare.sh, videolandOriginal/Port/Drive.singe): the drive
types a walkthrough a character a frame whenever the game waits for a
line (a solution of forty-five lines; with the drive's misses on, a
prologue of unknown words, inventory play, the restaurant's death, and
the coaster's car, each answered with a yes to play again), presses a
key where the game waits for one, and traces the state, the room, the
turns, the keyboard, the delays, the still, the clip's end, the count
of lines printed, and the newest line of the screen. Two of the
original's habits shaped the drive: the keyboard is still on for the
frame a wait for any key begins in, so a space pressed there goes into
the next line (the drive presses a full stop), and it stays on through
the bomb's delay, where a line typed is taken before the play-again
prompt and leaves the game in no state at all (the drive waits for the
delay). Both runs agreed to the line, each to the win: 218 trace
lines of the solution, 341 with the detours. The assembler keeps a
hand-written library title's fonts, as it does a Hypseus one's, and
Rollercoaster moved into `ported/` with its launcher.
**Hologram Time Traveler (2026-09-21).** RDG2010's Singe edition of
Sega's 1991 hologram game, converted for Singe 2 by POIU2020: a
tutorial and eight time periods, each a stage of segments shuffled at
the game's start (a random order and a random count, or every one by
dip), each segment a run of move rows (an input window, the move
wanted -- a direction, the action button, or a turn and then a shot --
a rest to the row's end, a death clip, an alternate death after a turn,
and the wizard's advice by kind), a death's clip chosen by the level,
the segment, the move, and what was pressed (a table of special cases
in code), the wizard after a death, reversal cubes that rewind the disc
frame by frame to the move's start, a map of the periods with a cursor
on a fifth-second delay and six seconds to choose, a trader who sells
cubes for coins, a hellgate whose lever rolls for lives, a continue
with a limit by dip, a game over, an LCD of scrolling lines on the
attract loop, and a board typed by the stick over the game's letters.
The port is the family's shape: the converter reads the title by its
own banner and runs its script under the stubs (its dips from the
config its service file names under `Cfg/`, its board from the same
file), and `Vocabulary.singe` beside the script transcribes the loop
(the start, the attract loop, a game's start, the level with its
windows, turns and shots, rests, deaths, cubes, and wizard, the map
and its cursor, the trader, the hellgate, the continue, the game over,
and the board) over the game's own files loaded through the shim: its
globals, its segment shuffles, its level setups and move rows, its
death-clip table, its intro and filler and advice clips, its clip and
timer helpers, its letters and board. The port draws nothing yet, as
the American Laser Games ports do not, and one habit is kept: the
hellgate names a roll the game never defines. The harness gained a
`timetraveler` family (compare.sh, timetravelerOriginal/Port/Drive):
the drive puts a coin in and presses START, answers each window three
frames in with the row's move (a turn, then the shot two frames on),
a wrong move on every fifth and none on every seventh with misses on,
skips the tutorial from the sign post with the right in perfect play
and plays it otherwise, spends a cube against the first death, visits
the trader once and buys cubes with coins, pulls the hellgate's lever
(with misses on: a roll can take every life), takes the continue, and
types a letter at the board; the trace carries the flow, the screen,
the state, the period, the segment, the move, the score, the lives,
the cubes, and the credits, but not the LCD's own state, which the
original's drawing steps while the attract loop shows. Both runs
agreed to the line: 488 trace lines with misses over forty thousand
frames (the tutorial, the first two periods, the trader, a death
rewound by a cube, the continue) and 1,494 of perfect play over a
hundred and thirty thousand: the tutorial and all eight periods with
the map between them, beaten without a death, the extended play the
game grants for that through all eight again, the board, and the game
over. (The first perfect run had pulled the hellgate's lever and lost
every life to the roll at the second period, so the perfect drive now
lets that clip pass.) Hologram Time Traveler moved into `ported/`
with its launcher, its port assembled (seventy-nine ports) and launched
clean.
**LINEA (2026-09-21).** BLADESCATER's 2023 fan game over a video, the
one title on the MazescaterFramework: Karis 3.31c (fifteen thousand
lines, 2,701 of them changed) with compound move kinds numbered 149 to
239 -- thirty-six sequences of two or three inputs in turn (UL, DLB,
DLU, and their kin), eight circles from any of four starts either way
(LOOPLEFTU and the rest), mashes of one direction or button (MASHLEFT,
MASHB2, MASHB3, each with a MIN and a MAX), MASH3 (BUTTON1 and BUTTON3
in turn), and a direction with BUTTON2 or BUTTON3 or two directions
with a button (ACT2UP, ACT3DOWNLEFT, ACTUPLEFT) -- each judged by a
check function of its own through the framework's step counter, its
mash flags, or its pressed flags; besides those, a longer look at the
hints (four seconds for two), MASH2 at six presses a second of the
window for nine, every direction and the third button counted for
either side of a mash, and DOUBLE numbered after MULTI (34 for 26).
The kinds are data with three shapes, so the qte loop gained them as
tables rather than branches: their numbers in QTE, MAZE_SEQ (each
sequence's inputs in turn; a circle is five, back to its start) judged
by one Q.checkSequence, MAZE_MASH (the input or pair a mash takes, its
count per second, its MIN and MAX) by one Q.checkMashOf, and ACT_WANTS
(the flags a combination wants together, the Karis pairs included) by
the branch that judged the pairs; the four other differences are gated
on the description's framework mark ("Mazescater 1.00", which the
converter writes when the framework folder beside the game is the
MazescaterFramework), and the assembler leaves that folder behind as it
does the Karis copies. The Karis drive had answered single inputs only
(a compound move timed out, which the earlier titles' runs allowed
for); it now plans the compound kinds of both frameworks -- a hold
pressed through the window, a let-go held from before it, a mash tapped
every other frame (a run, MASH2, and MASH3 alternating their two), a
sequence or circle a step every other frame, a multi as many times as
its row asks, a combination together -- and takes PORT_WRONG_EVERY and
PORT_MISS_EVERY for a run that goes deeper into a game with few lives.
Both runs agreed to the line: 166 trace lines under the drive's usual
policy (LINEA's five lives and one continue end at the thirtieth move)
and 870 over sixty thousand frames with a wrong answer every fiftieth
move and a miss every seventieth, through the fifth level's fifty-ninth
move, every one of the fifty-six kinds the game uses met and passed.
LINEA moved into `ported/` with its launcher, its port assembled (eighty
ports) and launched clean, and the release repacked.
**Platoon (2026-09-22).** POIU's 2020 prototype in the American Laser
Games shape, from the same hand as the library's Singe 2 editions: six
patrols (Charlie, Delta, Tango, Lima, Zulu, Bravo) of eight to twenty
moves each, every move a window to hit one man in, judged by the
editions' hit test against a hitmap of the frame whose boxes are, as
often as not, thin lines given back to front; a first press of the
trigger while a patrol's opening plays jumps past it, whatever the
state; a miss holds the disc at the window's end for the draw the
difficulty allows, then the move's death clip and the sergeant's after
it; the continue, the lives-left still, the board with an opening clip
of its own when Tango was beaten, and an attract loop the game files
under a level it never defined (its levelIntro is nil, and nil equals
nil). The port is the family's shape: the converter's alg mode reads
the dips and the board out of Script/platoon.cfg, and
`Script/Vocabulary.singe` (575 lines) carries the loop -- the six
patrols as one function over a table of what differs (the length, the
opening's skip, what follows), the intro, the continue, the lives left,
the board, and the gun's input, with the game's own level setups,
hitbox tables, hit test, and board functions run through the shim as
they are; the nil level is kept as the original has it, since the trace
must show it. The harness learnt three things from the title: the
alg family looks for the framefile under Script/Video too; the drivers
give the drive a target while the opening waits for its skip and name
the reload button (the second, as the Crime Patrol family) with no
spare bullet, since the release takes the bullet after the shot is
judged; and the drive's aim skips a box given back to front (the
game's own test can never hit one) and answers a move again in the game
the continue starts over. Both runs agreed to the line: 305 trace
lines with misses over sixty thousand frames (Charlie lost thrice and
beaten after the continue, Delta, Tango, the board, the game over) and
336 of perfect play over ninety thousand (all six patrols without a
death, the board with its Tango clip, the game over, the attract loop).
Platoon moved into `ported/` with its launcher, its port assembled
(eighty-one ports) and launched clean, and the release repacked. With
it the one-offs are done, and every game of the library the plan set
out to port has its port.
### 14.15 The HUDs and the service menus (2026-09-22)
The owner's verdict on 14.14's close: "Not porting the service menus or
HUDs means the ports are NOT COMPLETE. Finish your work!" Right: a
port that plays the loop but shows no score, no lives, no credits, no
prompt for the move, no board, and has no service screen to set its
dips on is a port of the loop, not of the game. What every family draws
and configures, and how each port gets it:
* **The KarisFramework** (twenty-eight ports and LINEA): the score in
the framework's number sprites (or rendered), the lives (sprites or a
count), the credits or INSERT COIN or FREE PLAY blinking, the level
title or scene number, the disk-slot and autosave marks, the get-ready
and skip prompts, the LCD ticker of the attract loop (typed out a
character at a time), the pause, the move prompt (`drawAction`, 534
lines: arrows that slide in from the edges, the buttons, the hold and
mash gauges, the loops, the combinations, the timed and multi kinds;
LINEA's framework adds its compound kinds and their hint texts), the
choice menu of a CHOOSE move, the board (the name entered on a grid of
forty-four cells, the rankings of the three modes, the percents, the
trophies), and five service screens (the options, the graphics, the
performance settings, the load/save slots, the exit) of 4,700 lines,
with the config file they write. The framework's copies do not travel
with the ports (14.8), and its loop was rewritten into Author.singe as
the qte behaviour rather than run, so its drawing and its screens are
rewritten the same way: a `qteHud` look in Author.singe that draws
from the state the behaviour keeps, and the screens as states of the
loop (`Q.doServiceMenu` and its kin), both as data where the framework
repeats itself (the option tables: a label, the values it cycles, what
gates it). The art travels: the assembler ships a title's Overlay/
(with Lores/) and Fonts/, and the framework's default font as
`Fonts/default.ttf`, since a look needs them and they are art, not
program.
* **The Kimmy Script Engine** (seven ports): its own HUD over a skin
(`FrameworkKimmy/Skin/DEFAULT`), the combo prompt (`drawAction`, 1,671
lines), the next-move preview, the life bar, the tilt, the warning, its
board, and its five screens (the new-game menu is already a state of
the kimmy loop). The same way as Karis: a `kimmyHud` look, screens as
loop states, the skin shipped with the port.
* **The map-mode lineage** (starblazers, freedomfighter, jeeg, hq-timegal,
Super Don Quixote): their `main.singe` already runs through the shim
for their levels, and holds their drawing and their one service screen;
an `rdgHud` look calls those functions as they are, under the shim's
sprite, font, and colour calls made real.
* **The American Laser Games titles** (thirty-two ports, seventeen
vocabularies): bullets, lives (hats, money), the score, the credits,
the crosshair and its recoil, the reload animation, the board's letters
and table, and the one service screen with the mouse as its cursor --
all in the games' own files, which the vocabularies already run through
the shim. Each vocabulary gains an `algHud` look that calls the game's
draw functions in the game's order (its `onOverlayUpdate` tail), and a
`levelService` state that runs the game's `doServiceMenu`; the SERVICE
switch, let go until now, opens it.
* **Hologram Time Traveler**: its LCD, action, lives, cubes, score, and
credits, and its service screen, from its own script, the same way as
the ALG titles. **Rollercoaster** draws its console already and has no
service screen. **ActionMax** shows its LED readouts through text and
sprite looks the converter writes, and the console had no service
screen.
Three things the runtime gains for all of them:
* **The shim's drawing calls are real.** `spriteLoad`, `spriteLoadFrames`,
`fontToSprite`, `spriteGetWidth`, `spriteGetHeight`, `spriteUnload`,
`spriteDrawFrame`, `colorForeground`, `fontSelect`, `fontQuality`, and
`getMiddle` do what the engine does, on files found where the port
carries them (`Q.gameFile`); a file that is not there gives -1, as a
stub did, so a game's load never ends on missing art. `overlayClear`
stays a no-op (Forge clears), and a look that drew with the game's font
puts Forge's back.
* **Dips persist.** A service screen's changes are written through the
engine's save store (`saveSet("forge.cfg", ...)`) and read at attach
over the description's dips, which remain the shipped defaults (what
"Default" restores). The Karis save slots go the same way. The
harness runs in a fresh data directory, so a comparison starts from the
description as before.
* **Two checks.** The drives gain a service walk (`PORT_SERVICE=1`: the
SERVICE switch at the attract loop, every option stepped, the exit), so
the screens' logic is traced against the original like the loop; and
`testScripts/ports/shots.sh` runs an original and its port to the same
moments (the attract loop, a prompt, the board, each service screen)
and screenshots both into `screenshots/ports/`, since what a HUD shows
is a picture, and a trace cannot say it is right.
The order: the runtime's three gains and the ALG family first (the
least new code: the drawing exists, the shim runs it), then Karis (the
most, and the pattern Kimmy follows), then Kimmy, the map-mode titles,
and Time Traveler; each family closed with its traces still agreeing
and its screenshots beside the original's.
**Parked (2026-09-22).** The first step of that order was taken and
then stopped: the shim's drawing calls are real (queued through the
behaviour's step and run by a look, with one sprite handle per file),
dips and boards persist through the save store, the twenty-six ALG and
Time Traveler vocabularies gained a `hud` look that calls the game's own
draw functions and a service state that runs the game's own screen, the
drives gained a service walk and screenshot moments, and shots.sh
gathers the pictures. None of it was verified past a syntax check, and
the owner's look at the sources ended it: "Most seem like a thin layer
of Forge bolted on to a whole hell of a lot of existing code. That's
not really meeting the stated goal of Forge." He is right, and
Rollercoaster is the sharpest case: its description has an empty
`rules` table and the whole game is 985 lines of Lua in its vocabulary.
The ALG family is the same at scale, 54,627 lines of vocabulary Lua
across twenty-six files, steering a shim that runs the games' own
setups, hit tests, boards, and (as of this step) drawing and screens.
By the measure that matters -- how much of the game lives in the
description and how much in Lua -- only the ActionMax five and the
framework loops meet the goal, and even Karis and Kimmy run the game's
script at attach. The rule that should have governed section 14, for
whenever this is taken up again: a port's vocabulary may add only
general-purpose kinds; a game's logic and data live in its description;
a family with mechanics Forge lacks gets them as runtime behaviours and
looks driven by data, never as the game's code and never as the game's
code rewritten by hand. The redo, in order: Rollercoaster (the
smallest, and the only one that exercises the authoring side -- rooms,
the parser layer, rules, sequences, vars, a console look -- so its gaps
say what Forge is missing for adventures), then the ALG family (one
generic behaviour and look, the converter extracting hitboxes, frames,
boards, dips, and service options, the vocabularies gone), then Time
Traveler, the map-mode five, and the Karis and Kimmy attach. The work
is filed here; the owner's word was "get back to actually working on
Singe."
### 14.16 The pixel reading, into the framework (2026-09-23)
The owner asked whether the pixel reading could be added to Forge itself
rather than sitting beside five games, and it could: it was general all
along. `AUTHOR.behaviours.lightSensor` is in the manifest now, in the gun
cluster after `gun`, `pointer`, and `target`, and the read behind it is one
runtime function, `authorPictureState(x, y, high, low)`, in the section about
judging shots over the picture. Nothing about it was ActionMax's: the spot,
the corner it may not be shot in, the two thresholds, the trigger, and the
player are parameters, and the numbers live in each description as they
always did.
`AUTHOR.conditions.pictureAt` came with it, so a rule can ask what the
picture is at a point without a sensor at all -- a flash hidden in a game's
own video, a colour cue, a lamp in a scene. The two thresholds are
`PICTURE_BRIGHT` and `PICTURE_DARK` beside the other tuning constants, named
once and read by both. A game with no disc reads dark everywhere, since
`vldpGetPixel` answers zeroes when no video is playing.
`util/forgePortActionMax.py` no longer writes a `vocabulary` line, the five
descriptions are regenerated without one, and the file they used is kept
aside as `Vocabulary.singe.old`. All five comparisons still agree with their
originals. So the zero-vocabulary count is 54 of the 81 descriptions, and
the ActionMax five are now ports in the full sense: a description, the
framework, and nothing of their own. `docs/ForgeVocabulary.adoc` is
regenerated from the manifest, and the book's "Your Own Vocabulary" now uses
the test scene's `bob` as its example, with the rule that a word two games
would want belongs in the framework.
Scene 100 (`testScripts/author/picture.forge`) covers both words from the
repository alone, since the ActionMax five are the only other thing that
exercises them and they live outside it: the description compiles, the built
game calls `authorPictureIs`, the framework has the behaviour, and the three
answers add up to the frames drawn. Run plain, every frame must read dark,
because no video is playing; run with `-v Singe/menuBackground.mkv`, the read
must see something other than those zeroes. Which of bright and neither it
sees is the video's business: the menu's backdrop is mid-tone, so it reads
neither on 119 frames of 120 and never bright. Both runs are clean.
### 14.17 How much of a QTE port is really in its description (measured 2026-09-23)
The owner put the obvious question to 14.5: the ports are called ports, yet
the loop calls the game's own hooks. So: how much of each port would still
play with the game's Lua taken away? It is a one-line experiment, since the
`qte` behaviour only loads what the description names -- delete its `script`
and `addons` lines and the loop has nothing but the snapshot the converter
wrote. Five titles, each compared against its original as 14.1 compares them:
| Title | Hooks it defines | Without its Lua |
|---|---|---|
| Time Gal | setupMoves, swapScene, doLevelSelect | plays as the original, 186 trace lines |
| Asterix | setupMoves, swapScene, doLevelSelect | plays as the original, 218 trace lines |
| Tron | setupMoves, swapScene | diverges at level 1 scene 5 |
| Dragon's Lair Enhanced | setupMoves, startConf, swapLevel, specialScore | errors in `Q.doMixTIE` |
| Space Ace Enhanced | setupMoves, startConf, swapLevel, swapDeath | diverges |
So the hosting is not what makes a port play: for two of the five the
description already holds everything, and `Q.loadMoves` reading the snapshot
when no `setupMoves` exists is not a fallback nobody uses -- it is the whole
game. Where it is load-bearing, what is missing is nameable. Dragon's Lair
Enhanced fails on `q.Tiers[0]`: the tier tables that decide which levels are
mixed into a run are declared by the game's script and the converter does not
snapshot them. Tron's divergence is its `swapScene`, a per-difficulty choice
of scene that the snapshot does not carry either. Neither is a mechanic Forge
lacks; both are data the converter has not been taught to lift.
The next step for 14.5, then, is not a redesign: teach `forgePortKaris.lua` to
write `Tiers`, `PlayOrder`, and the swap decisions into the description, give
the loop a `mix` of its own to read them, and re-run the comparison with the
hooks gone. Every title that then plays without its Lua is a title whose port
is a description, which is the measure 14.15 set. Thirty-eight of the
seventy-five descriptions define `setupMoves`, so that is the size of the prize.
### 14.18 What "complete" means for a port, and where each one stands (2026-09-24)
The owner asked for a port to be called complete only against something
checkable, after this session called ports "playing as the original does" on the
strength of a trace comparison that never looks at the screen. That phrase is
the harness's and it means one row of the table below. So: a port is complete
when every row holds for the same inputs, each row has a check of its own, and a
claim names the row and the check rather than the word.
| Row | What it means | The check | Where the framework ports stand |
|---|---|---|---|
| State | The same moves judged at the same frames, and the same score, lives, and level | `testScripts/ports/compare.sh` compares traces | 19 titles agree with none of the game's Lua; see the matrix |
| Picture | The same screen: arrows, life bar, score, prompts | Screenshots diffed at fixed frames. Not built | Nothing of the game is drawn at all |
| Screens | Service menu, save menu, name entry, two-player, movie player, each reachable and behaving | A drive into each. Not built | Not played: the loops send those states back to the attract loop |
| Sound | The same clips at the same moments | Not built | The framework's eight sounds play, and the game's own play through the shim; unverified |
| Persistence | Dips, saves, and boards survive a restart | Not built | Unknown |
| Controls | Every switch the original honours, the service key included | Not built | Partial |
| Independence | The port runs with none of the game's own files | `util/forgeStandalone.py` | 19 of 36 titles |
Three rules go with it, and they bind the writing as much as the work:
* **Name the check, not the conclusion.** "The traces agree" is a fact. "Plays
as the original" is a claim about rows nobody tested.
* **A row with no check is unknown, not done.** Sound, persistence, and controls
above are unknown, and saying nothing about them is not the same as covering
them.
* **Complete means every row.** Anything less is named by its rows. These ports
are state-complete and, for 19 titles, independent. Nothing more.
Why the drawing is missing rather than broken: the shim collects every drawing
call a game makes into a queue (`Q.queueDraw`), and a look is supposed to flush
it. `authorFlushDraws` is there and nothing calls it, because the look that
would have belonged to the drawing work parked in 14.15. A description's game
entity therefore takes the `none` look, and the queue is dropped every frame.
**The matrix.** "Without its Lua" is the standalone sweep of 2026-09-24, run
after the converter was fixed to snapshot the tier tables and the scene swaps
(14.16 is the pixel reading; the swaps are in this session's converter work).
"As it ships" is the same comparison with the game's own files where they are,
which is only interesting for a title that needs them.
| Title | Family | Without its Lua | As it ships |
|---|---|---|---|
| AlteredCarbon | karis | agrees | agrees (ships without it) |
| Arcade_Xperience_Vol1 | hypseus | agrees | agrees (ships without it) |
| Arcade_Xperience_Vol2 | hypseus | differs | DIFFERS |
| Arcade_Xperience_Vol3 | hypseus | differs | DIFFERS |
| Asterix | karis | agrees | agrees (ships without it) |
| Astroboy | hypseus | differs | DIFFERS |
| badlands_lite | hypseus | agrees | agrees (ships without it) |
| BD13 | hypseus | differs | agrees |
| ChantzesStone | karis | agrees | agrees (ships without it) |
| CliffHanger | karis | agrees | agrees (ships without it) |
| Danmachi | hypseus | differs | DIFFERS |
| DragonsLair2Enhanced | karis | agrees | agrees (ships without it) |
| DragonsLairEnhanced | karis | differs | not checked |
| Esh_Aurunmilla | hypseus | differs | agrees |
| FBC | hypseus | differs | DIFFERS |
| FireAndIce | karis | agrees | agrees (ships without it) |
| Freddy | hypseus | differs | DIFFERS |
| FridayThe13th | karis | differs | DIFFERS |
| FumaConspiracy | hypseus | differs | DIFFERS |
| hayate_1080 | hypseus | differs | agrees |
| Linea | karis | agrees | agrees (ships without it) |
| mazinga | hypseus | agrees | agrees (ships without it) |
| Mononoke | karis | differs | DIFFERS |
| NinjaHayateHD | karis | agrees | agrees (ships without it) |
| OeilPourOeil | karis | agrees | agrees (ships without it) |
| sintel | hypseus | differs | agrees |
| Sonic_the_Hedgehog_1996 | hypseus | agrees | agrees (ships without it) |
| SpaceAceEnhanced | karis | differs | not checked |
| StarshipTroopers | hypseus | differs | agrees |
| SuckerPunch | karis | agrees | agrees (ships without it) |
| Sugar_Rush | hypseus | differs | DIFFERS |
| Survival | hypseus | agrees | agrees (ships without it) |
| TheElderScrolls | karis | agrees | agrees (ships without it) |
| TimeGal | karis | agrees | agrees (ships without it) |
| TitanAE | karis | agrees | agrees (ships without it) |
| Tron | karis | agrees | agrees (ships without it) |
Ten ports differ as they ship, which is the finding this table exists for:
Arcade Xperience Volumes 2 and 3, Astroboy, Danmachi, Future Boy Conan,
Freddy, Friday the 13th, The Fuma Conspiracy, Mononoke, and Sugar Rush. Every
one of them was bisected and every one predates this session: each was run
with its own pre-session description and the pre-session runtime (721a0d120,
the 13,628-line Author.singe, before the scene swaps) and differed there too.
So nothing in 14.16 or 14.17 caused them, and the cause of each is still
unknown -- what is known is that a port that differs as it ships is not
state-complete, and none of the ten should be offered as playable until it is.
A trap for whoever bisects next: do not take HEAD for the pre-session
runtime. The owner commits while work is in progress, so a session's own
changes can already be in HEAD -- 25aa909c3 carries the scene swaps -- and the
first bisection here compared the session's runtime against itself and proved
nothing. Name the commit.
Seven ports keep their Lua and agree: Dragon's Lair Enhanced, Space Ace
Enhanced, Sintel, Esh's Aurunmilla, Starship Troopers, Brain Dead 13, and
Ninja Hayate 1080. Those are state-complete but not independent. With the
nineteen that stand alone, that is twenty-six of thirty-six titles
state-complete and ten not.
### 14.19 The ten that differed, diagnosed (2026-09-26)
All ten ports that differed as they shipped (14.18) now agree: the same
traces as their originals under `testScripts/ports/compare.sh`. That is the
State row and nothing more; the other rows of 14.18 are as they were. Three
causes, all in the runtime rather than in any description, and none of them
new: each predates 721a0d120, as 14.18's bisection already said.
**How it was run.** A binary of its own, so another session could keep
working on the one in `.builddir`: the inner project configured directly
(zig, Release, the default preset's installed libraries, `SINGE_SUPERBUILD`
off) into `~/claude/portwork/build`, built once from the working tree on
2026-09-26 and left frozen for every comparison below. Configured that way
nothing copies the binary into `.builddir`. The diagnosis used copies of the
drivers outside the repository with two additions: a per-frame line of the
game's flow, screen, step, and disc frame after each update, and a line for
every `disc*` call with the frame it was made on. Diffing those two runs
frame by frame finds the first frame the port parts from the original, which
the traces cannot: a trace records disc frames, so a port that is one
overlay frame behind the original agrees on every line but the last.
**1. The countdowns' arithmetic** (Astroboy, Friday the 13th, Mononoke,
Sugar Rush). Astroboy's traces differed only in their last line, 30400
frames against 30401. The per-frame diff put the parting at frame 2783:
the difficulty screen, whose `timerON(30)` ends it after thirty seconds, let
go a frame later in the port and the port stayed a frame behind from there
to the end. Thirty seconds on the deterministic clock's 15 ms step is
exactly 2000 frames, so the frame the timer is due is decided by rounding.
The framework sums `os.clock()` deltas, `iSecs = iSecs + thisSeconds -
lastSeconds`, which Lua evaluates as `(iSecs + thisSeconds) - lastSeconds`,
and compares the sum; the port compared `authorTime() - from` against the
limit. Both are the same ticks, but the two sums round differently, and on
the frame that matters one reaches 30 and the other falls a hair short.
Every copy of `timerDue` in the library (148) and of `joyDelayDue` (95) does
the framework's arithmetic, so the port's timer and joystick delay now share
one countdown that does exactly that, with `os.clock()` as the clock, and
holds while the game is paused as the framework's does.
**2. The language** (FBC, Fuma Conspiracy, Danmachi's start). FBC's traces
differed on one line: the first screen of a game was traced at disc 328 in
the original and 329 in the port, and everything after agreed. The
per-frame diff showed the same state on every frame, so the difference was
between frames: the call log showed the original calling `discAudioSuffix`
on the frame the game starts and the port calling nothing. That call is the
framework's `setLang`, run by `readConfig` from `initJob`, and it holds the
disc for the frame. `readConfig` only calls it when the saved config has a
`dip_Lang` line; Astroboy's and Arcade Xperience's configs have none, which
is why they agreed. More than a frame: Fuma Conspiracy's config says
`dip_Lang = 1`, and `Video/fuma-it.ogg` is there, so the original plays in
Italian and the port played the default track. With the fix, both runs call
`discAudioSuffix("-it")` on frame 677 (checked from the call log; nobody has
listened to it). The converter records a dip only when a config file names
it, so the snapshot having `dip_Lang` is exactly the condition, and the
KarisFramework and Kimmy loops now call the framework's `setLang` on it at
init.
**3. The game over after a continue** (Arcade Xperience 2 and 3, Danmachi,
Freddy). At game over the original seeks to the game-over clip and the port
did not. The port's `doGameOver` came from 3.31c, which plays on from the
continue clip when the disc is at its end; 3.32b always seeks. All the
test library's own ports are 3.31c; the Hypseus library's KarisFramework
titles are 3.32b. The shortcut now applies to 3.31c only.
| Title | Family | Now | Cause |
|---|---|---|---|
| Arcade_Xperience_Vol2 | hypseus | agrees, 188 lines | 3 |
| Arcade_Xperience_Vol3 | hypseus | agrees, 224 lines | 3 |
| Astroboy | hypseus | agrees, 226 lines | 1 |
| Danmachi | hypseus | agrees, 83 lines | 2, 3 |
| FBC | hypseus | agrees, 97 lines | 2 |
| Freddy | hypseus | agrees, 165 lines | 3 |
| FridayThe13th | karis | agrees, 344 lines | 1 |
| FumaConspiracy | hypseus | agrees, 94 lines | 2 |
| Mononoke | karis | agrees, 369 lines | 1 |
| Sugar_Rush | hypseus | agrees, 289 lines | 1 |
**Then shown.** Causes 1 and 2 change what every KarisFramework and Kimmy
port does, including the twenty-six that already agreed, so they were not
"fixed" until the whole sweep agreed again on the same binary; it did (the
sweep, below). Also noted and not changed: the Kimmy
loop's end of the attract clip leaves `bPause` and `lvlState` alone where the
framework resets both, which shows in the per-frame state for one frame and
in no trace.
**The sweep, on the same binary (2026-09-26).** Every one of the 36 titles
of 14.18's matrix agrees on State with causes 1 and 2 in place: the 26 that
agreed before still do, and the ten above. So do the map-mode titles outside
the matrix (Conan, Daitarn 3, the TV Show, Dragon Trainer, Puss in Boots,
Samurai Jack, Super Don Quixote, Freedom Fighter, Time Gal HD, Star Blazers)
and the rest of the Hypseus titles (Mazinga, Sintel, Sonic, Starship Troopers,
Sugar Rush, Survival). That is State only, as before.
**What the sweep itself got wrong.** `sweep.sh` ran every description under a
library `Script/` folder with the KarisFramework driver, whatever its `kind`,
so the gun games, the time-travel game, and the text adventure each produced
a trace of one line and "differed" for that alone. It now picks the driver
from the description's `kind` (`familyOf`), as its Hypseus loop already did.
**Three defects the gun games had, found on the way.** The shim's
`Q.loadSprite` called `spriteLoadFrames(path, frames)`, the wrong way round
for the engine's `spriteLoadFrames(count, filename)`, and a Hypseus gun game
stopped at its first sheet. A game that asks for the old sprite argument
order and loads the engine's `Framework.singe` through the shim got its
`spriteDraw` wrapped twice, once by the framework and once by the shim, so a y
became a sprite handle; the shim now lets the framework wrap and then takes
back the two calls it converts itself. And one game's vocabulary reimplemented
`initJob` without the score digits' positions and sprites, which only showed
once an uncapped run reached the score. The Reference's `spriteLoadFrames`
example had the arguments reversed too, and is corrected.
**Outside the matrix, with the right drivers (2026-09-26).** Every other
family now agrees on State too -- the gun games in both libraries (Crime
Patrol HD in both, Drug Wars and its HD, Last Bounty Hunter and its HD, Mad
Dog McCree II and its HD, Space Pirates and its HD, Who Shot Johnny Rock and
its HD, Johnny Rock Noir, and the typing edition), Platoon, Jeeg, the
time-travel game, and the text adventure -- but for the exceptions below.
Getting there took, as
well as the three defects above: the vocabularies' own `initJob` put back what
the original's draws on (score digits, lives stars, the LCD's words, the
arrows), because an uncapped run reaches the drawing and the drawing stopped
the run; the time-travel game's picture drawn only while the game runs, as
the original's is; Last Bounty Hunter HD's pointer setting the cursor and the
shot's mark as the original's `onMouseMoved` does; the shim's
`spriteDrawFrame` taking every old form (with a scale, or two); the drivers'
pair kinds skipping a constant the game does not define (Jeeg has no
diagonals); and `compare.sh` finding a gun game's video when it keeps no
framefile, and saying so when a title has no video at all.
The exceptions, open: **the library's Crime Patrol** (not the HD) left no
result in the batch; run alone, its two traces are the same 321 lines, but
neither run ends with the drive's end line, so both were stopped by
`compare.sh`'s hour rather than finishing. It agrees as far as both got, and
why its drive never reaches an end is not known. **Mad Dog McCree HD** misses one seek, entering the town
select after a continue (the original seeks back to the menu frame, the port
is five frames further on), and agrees before and after it; finding why wants
the per-frame instrumentation of 14.19 on the gun-game drivers. **The
Hypseus `WSJR_HD`** is not compared: its description sits at the title's top
level rather than in `Script/` beside its script, where 14.13 says it lived
when it agreed, so no driver finds it; where it belongs is the owner's to say.
All of this is the State row; the drawing that now runs is not checked.
**Where the fixes live, and why that is temporary.** All three went into the
framework-emulating loops in Author.singe, beside the code they correct:
`Q.setLang`, a `Q.karis332` check, and the framework's countdown. That is
framework knowledge in Forge's runtime under framework names, which section
15 sets out to end. They move with the rest of the loops when it is done.
## 15. Design: Forge as its own thing (written 2026-09-26)
The owner, after 14.19 put three more framework fixes into Author.singe:
"I want Forge to be as generic and capable as possible. If cloning the Karis
framework is what provides Forge with QTE capabilities, that's fine. But we
can't call it 'Karis' or refer to any other third party framework name.
Ideally, Forge is its own thing that is flexible enough to handle everything
these other frameworks provide." And: cover everything Forge can do, not
just QTE, and where an existing game does something Forge cannot, find a
generic way to support it.
This section is the plan for that. Nothing in it is built. It was written
from four surveys made the same day -- Forge's whole vocabulary and editor,
the engine API against what Forge can reach, every family in the library
against what its port needs, and the five game loops in Author.singe side by
side -- and every defect it reports was checked against the code before it
was written down (15.7).
### 15.1 The rules
1. **No third-party names in Forge.** Not in a behaviour, layer, look,
event, condition, action, parameter, or `kind`; not in help text, the
generated vocabulary, the editor, the book, or the runtime that ships
inside every release. A framework, engine, company, or game named as the
*source* of a behaviour is the thing this rules out. Library and format
names a user needs to know (glTF, RmlUi, Jolt, WAV) are not sources of
behaviour and stay.
2. **What a framework does becomes a Forge capability.** Cloning a
framework's behaviour is fine; shipping it as that framework is not. The
capability gets a Forge name, a Forge data model, and is open to every
game, not only the ports it came from.
3. **Differences are options, not versions.** Where two frameworks (or two
versions of one) disagree, the difference is a named option with a plain
meaning, set in the description. Nothing at run time asks which framework
or version a game came from.
4. **A game's logic is data, or its own vocabulary.** What a description
can say, it says. What only one game does goes in that game's
`Vocabulary.singe` (14.7), under names that are the game's own business.
Running a game's original Lua through a shim is a porting stage, not a
destination.
5. **Only the converter knows where a game came from.** It has to read
those games, so it may name them; everything it writes uses Forge's
vocabulary. It is renamed from `util/forgePortKaris.lua` to a generic
name when the rest moves.
### 15.2 What Forge can do today
For the record, so the gaps below are measured against something. The
vocabulary is `docs/ForgeVocabulary.adoc`, generated from Author.singe.
* **Layers:** bezel, disc, hud (an RmlUi document with ids bound to vars),
music, overlay, parser, scene3d, world2d.
* **Looks:** billboard, box, grid, light, mesh, model, none, particles,
sprite, text, text3d.
* **Behaviours:** animator, body, branching, camera, character, drift,
frames, gun, health, hitbox, hotspot, joint, keys, lightSensor, mover,
patrol, platformer, pointer, projectile, racer, seek, shooter, soft,
solid, sound, spawner, spin, target, thrust, timer, trigger, turret,
vehicle, walker, water; and the five game loops (15.3).
* **Events:** animationDone, arrived, branchOpen, branchTaken, branchMissed,
collision, death, enter, leave, frame, frameReached, gameOver, hit, miss,
midi, patrolEnd, pressed, released, railEnd, roomStart, roomEnd, said,
soundDone, spawn, stopped, timer, verb.
* **Conditions:** below, chance, discBetween, every, has, hitPart, hover,
inState, keyHeld, keyPressed, onGround, onScreen, once, pictureAt,
pointerIn, pointerOffscreen, switchHeld, test, timeBetween, touching,
waiting.
* **Actions:** addScore, addVar, setVar, credit, damage, destroy, spawn,
spawnAtPointer, show, moveTo, setState, setText, die, gameOver, restart,
discPause, discPlay, discTo, emit, fade, flash, shake, face, lookAt,
walkTo, walkToHotspot, walkToPointer, goTo, jump, run, saveGame,
loadGame, lua, nextVerb, setVerb, useItem, give, take, pathNext,
cameraCut, playAnimation, push, reload, playMusic, stopMusic, playSound,
say, talk, submitScore, timerStart, wait.
* **Structure:** types and instances with vars and states; rooms that keep
their state; rules with triggers, `when`, `do`, `each`, `interrupt`, and
cut-scene `controls = false`; expressions; tracks (hitbox boxes keyed by
frame or time, branches, rail points with stops, racer lines); waves;
sequences as coroutines; dialogue trees; walk polygons baked to a
navmesh, hotspots, verbs, and a verb/noun parser; whole-game save slots
and a best score; a per-game vocabulary file.
* **Editor:** a chooser, five panels (entities, types, rules, tracks,
dialogues), manifest-driven fields with pickers, rename that follows every
rule, polygon drawing, a timeline strip over the disc, a 3D viewport with
gizmos, undo and redo, save, build with diagnostics, play from the room,
export, and publish to the master service.
Of the engine's 510 registered functions, Forge's vocabulary reaches 169;
24 more are reached only by the game loops; the other 317 are reachable
only through the `lua` action or a game's vocabulary file. 15.5 takes the
ones worth a Forge feature.
### 15.3 Third-party names to retire
| Where | Now | Becomes |
|---|---|---|
| behaviour | `qte` | stays `qte`: the one Forge QTE behaviour (15.4), which the other four fold into |
| behaviour | `kimmy` | folded into `qte`; its differences are options |
| behaviour | `rdg` | folded into `qte` (segmented levels, level map) |
| behaviour | `timegal` | folded into `qte` (checkpoints, a timed choice, quota lives) |
| behaviour | `sdq` | folded into `qte` (trophies, percent finish, order after a beaten or unfinished level) |
| game vocabularies | `kind = "alg"` (25 titles) | a Forge `gunGame` capability built from 15.5's gun model; until then, each game's own vocabulary under a neutral name |
| game vocabulary | `kind = "timetraveler"` | the game's own vocabulary under a neutral name, then Forge features (15.6) |
| game vocabulary | `kind = "videoland"`, look `console` | a Forge `console` look and a Forge text-adventure capability (15.5, text input) |
| game vocabularies | look `hud` (ALG, the time-travel game) | renamed: it collides with the `hud` layer, and becomes 15.5's HUD widgets |
| help text | `branching`: "Dragon's Lair:" | a plain description of branch windows (help text is Forge itself: the editor and the generated vocabulary show it) |
| help text | `parser`: "the Sierra way"; `die`: "The Sierra death" | plain descriptions, for the same reason |
| help text | `qte`, `kimmy`, `rdg`, `timegal`, `sdq`: KarisFramework, MazescaterFramework, Kimmy Script Engine, Karis's 2024 framework, RDG's lineage, RDG2010, Time Gal, Super Don Quixote | the folded `qte`'s own help |
| runtime | section headers naming KarisFramework, the Kimmy Script Engine, RDG's map mode, Time Gal, Super Don Quixote; comments naming Hypseus, American Laser Games, Mad Dog, ZeroBrane | Forge's own section names; comments say what, not whose |
| runtime | `KIMMY`, `KIMMY_MASK`, `KIMMY_BIT`, `Q.mazescater`, `Q.karis332`, `q.hayate`, `q.kimmy`, `q.rdg`, `q.timegal`, `q.sdq`, `qte.framework` | gone: options (15.4) and one constants table |
| data | `qte.framework = "3.31c"` and kin in every ported description | gone; the converter writes the options instead |
| book | the ports paragraph (KarisFramework, Hypseus, LINEA, Mazescater, Kimmy, RDG, Time Gal, Super Don Quixote, American Laser Games, Mad Dog McCree, Platoon) | Forge's QTE and gun-game chapters, in Forge's words |
**Describing a style of game in the manual is fine** (the owner, 2026-09-26):
the book may say an adventure plays the way Sierra's and LucasArts' did, that
branch windows are the Dragon's Lair kind of game, that a tutorial builds a
gun game in the manner of Mad Dog McCree, or that the light sensor serves
ActionMax tapes. Those names describe what the reader already knows. What
they may not do is name anything in Forge itself -- a word of the
vocabulary, a help string, the editor, the runtime -- or present a Forge
capability as a framework's. The book's paragraph about the ports still
goes, because it names frameworks as the source of Forge's behaviour.
### 15.4 Forge's QTE: one behaviour, its own model
Everything the five loops do, as one behaviour whose model is Forge's. The
loops already share one skeleton; what differs is a list of choices.
**The game's shape.** A title clip, then the attract loop, then play. The
attract loop is a list of steps, each a clip or a still with a hold time,
any of which a button skips; credits (coins or free play, a cap), a start
input, an optional secret input sequence that starts a hidden level. Play
is levels of scenes of moves, with these screens around it, each optional
and each a clip or a still with its own inputs: difficulty select, level
select or map, new-game menu (new, continue an autosave), level intro and
get-ready, hints on a death, continue, game over (normal and alternate),
level clear with a bonus roll, finish (points, percent, or a tally), high
score, trophies.
**A move.** A window (`from`, `to` on the disc), a judge, and what follows:
where play continues on a right answer, and what a wrong answer or no answer
does -- a death clip (named, from a list, or drawn at random), harmless, or
counted as right. A path move names where each answer leads (another move,
a later scene, a death, the end of the scene). Frames may be written
relative to the level's intro. Scenes may be mirrored (a random draw per
scene, an offset for the scene and for its deaths, left and right swapped).
**Judges** -- the kinds, named for what the player does: press (one input,
optionally a second accepted one); chord (inputs together, nothing else);
hold (for a length the difficulty sets); letGo (held at the start, released
by the end); mash (a rate of presses of one input or alternating inputs,
decaying, scaled by window length and difficulty); count (an input pressed
n times); sequence (inputs in order); circle (the stick round, quarter,
half, or whole, then an input); choose (n options shown shuffled, a timeout
default); path; yesNo; timed (an input within a sub-window); skip (any
input skips ahead); jump (to a scene or level, no input). Each judge's
arithmetic -- how a mash decays, how long a hold must last at each
difficulty -- is a parameter with the frameworks' values as presets, not a
second implementation. Tilt (inputs outside a window count against the
player, a warning at half the limit) is an option on the behaviour.
**The rest of the model.**
* *Lives:* a count per credit, a life bar (size, cost of a miss, right
moves to regain one), one life, or none (practice); extra lives every n
points or at a quota and every m after.
* *Retry after a death:* restart the scene, replay the move from a lead-in,
restart the level, carry on from the next move, or resume from the last
checkpoint; per level, what a failed level does (replay, move on, one
retry, come back later in the order).
* *Order:* sequence, tiers, random with a fixed last level, map, or level
select; optionally skipping beaten levels, and a next level chosen by a
rule (15.5, progression).
* *Scoring:* per move (plus per difficulty), per scene, a death penalty,
per level, a no-death bonus, finishing the game, the secret level; or
moves counted, or deaths counted, or a percent; a cap.
* *Difficulty:* 0-3, each with a window delay, the judges' presets, and
which hard judges fold into simple ones at the easiest setting.
* *Extras:* per-difficulty scene swaps; clips a setting turns on
(get-ready, after-death, level clear); a preview of the next move; a
prompt sound; the disc's audio language (15.5).
**Options.** The switches the five loops use today, each as what it means:
| Today | Option |
|---|---|
| 3.32b's always-seek game over after a continue (`Q.karis332`) | `gameOverAfterContinue = "seek"` or `"playOn"` |
| 3.32b's secret input, and the later map-mode copies' | `secret = { inputs = {...} }` |
| Mazescater's compound kinds, 4 s hints, mash rate 6, any switch counts for a mash | the sequence and circle judges; `hints = 4`; the mash judge's `rate` and `anySwitch` |
| Kimmy's held-input mask | the chord judge (it is exactly "these inputs, nothing else") |
| Kimmy's text arguments for loops, paths, and multis | the circle, path, and count judges' own fields |
| Kimmy's new-game menu, level select, exit menu, tilt, next-move preview, percent game | screens and options above |
| `q.later` (map-mode copies) | `clipEnd = "exact"` or `"atOrPast"`; attract holds as data; a quit input |
| `q.hayate` (a later Kimmy copy) | `extraLifeInTwoPlayer`, `retryAtLevelStart`, `creditCap`, `continueRestartsLevel`, `promptSound` |
| `q.MazeGame` | `order.advance = false` |
| `q.tweaks.*` (per-title, map-mode) | the option each tweak stands for |
| `dip_GameType` (five different meanings across the loops) | split into `lives`, `scoring`, `simplify`, and `order` |
| `dip_PlayStyle`, `dip_Rewind` (numbered differently in two lineages), `dip_Extravid`, `dip_Hints`, `dip_Display`, `dip_ShowAction`, `dip_MashtoRun`, `dip_HoldtoLoop`, `dip_MashRes`, `dip_Tilt`, `dip_Kidmode`, `dip_MustBeatLevel`, `dip_SortStyle` | named settings (15.5, settings), each with the values above |
| the snapshot's `dips` present for `dip_Lang` (14.19) | `languages` and the chosen one |
**The data.** The description's `qte` table today is a dump of the
original script's globals under the frameworks' names (`dip_*`,
`offsetGameOver`, `LangOpt`, positional move rows, a `settings` table of
every global) with a separate snapshot of levels. It becomes Forge's model:
`clips` (title, attract steps, get-ready, continue, game over, alternate,
high score, rankings, clear, finish, hints, secret), `deaths` (a list of
clips), `levels` (name, intro, mirror, what a failure does, scenes of named
move rows, per-difficulty variants), `scoring`, `lives`, `order`, `secret`,
`board`, `saves`, `languages`, and the options. One source for each:
today level metadata lives twice (`levels` and `settings.Level`), the
constants are copied from `settings` over Author's own table, and a dip can
be in both `settings` and `dips`.
**Hooks.** What data cannot say stays in the game's own vocabulary as named
hooks the behaviour calls (a move's score, the next level, a death clip),
and 15.6 turns the common ones into data.
**Drawing.** The loops compute what a player should see -- `bShow*` flags,
the prompt, score, lives, the bar -- and draw none of it. With the model in
Forge's hands, that becomes 15.5's HUD widgets bound to the behaviour's
vars, which is the Picture row of 14.18.
### 15.5 What Forge needs, for every kind of game
Each item is a capability some game in the library has, or the engine offers
and Forge does not reach, phrased as a Forge feature. "Needed by" names who
is waiting on it; "engine" means the engine already has it and Forge only
has to reach it.
**Game structure**
* **Random choice among authored alternatives.** A `choose` field wherever
a value is written: one of these clips, orders, scenes, move rows, or
deaths, drawn once per game, level, scene, or use, from the game's one
seeded stream. Needed by: the seven hosted titles (15.6), the map-mode
titles, the gun games, the time-travel game.
* **Progression as data.** A level's successor as a rule: sequence,
tiers, random, or "the first of these whose condition holds" over what is
beaten, collected, or set; skip beaten; a bonus or secret level on a
condition. Needed by: Space Ace Enhanced, Brain Dead 13, Starship
Troopers, the gun games with outlaw chains, every QTE family.
* **Per-level and per-move overrides.** Any constant (a move's score, a
clip, an extra-life step) overridable on a level or a move. Needed by:
Dragon's Lair Enhanced, Aurunmilla, Ninja Hayate 1080.
* **Counters, quotas, and tallies.** Named counters with a deadline (a turn
limit), countdowns, streaks, and a tally screen with a formula. Needed
by: the text adventure's bomb, the gun games' streaks and crystal clock,
the finish tallies.
* **Settings.** A declared list of the game's options (label, values,
default, what each gates), persisted, with a generic options screen and
restore-defaults; any field of a description can be gated on one. This
is what the frameworks' dips and service menus are. Needed by: every
family.
* **A service mode.** Entered by the service input, drawn by the options
screen, with the pointer as a cursor. Needed by: every family (14.15).
* **Score boards with name entry.** Several boards per game (by difficulty
or mode), extra columns, entry by letter grid, stick, keyboard, or
on-screen keys; shown locally and, with the master service, online
(engine: `scoreBoard`, `scorePlayerName`, `scoreBegin`). Needed by:
every family.
* **Save slots with the game's own vars.** Progress plus any flags or
collectibles, a load/save screen, erase slot, reset best (engine:
`saveDelete`, `saveClear`). Needed by: the QTE families, Brain Dead 13.
* **Two players.** Alternating (one set of controls, turns, per-player
vars) and simultaneous (per-player vars, device routing, twin boards, a
tie-break). Needed by: the QTE families, the gun games.
* **Attract mode and movie mode.** An attract cycle of clips, stills, and
fillers with a timed "press start"; playing a game straight through as a
film. Needed by: every QTE family; kiosks.
* **Languages.** A list of languages with an audio suffix or track each
(engine: `discAudioSuffix`, `discSetAudioTrack`) and per-language text;
chosen in settings, applied at start. Needed by: the Hypseus QTE titles,
the Italian titles, the gun games with Spanish audio.
**Input and judging**
* **A judge vocabulary for any game**, not only the QTE behaviour: 15.4's
judges as conditions a rule can use over any window, so an action game can
ask for a chord or a mash as easily as a QTE can.
* **Choices and menus over video.** N options on a still or a clip, as
panels, quadrants, or cursor positions, shuffled or fixed, with a timeout
default and skip-if-done. Needed by: Time Gal's time-stop, the TV Show,
the gun games' menus, every map.
* **A map or level select.** Positions, cursor movement rules, completion
state, and a still per level. Needed by: Sucker Punch, Friday the 13th,
Starship Troopers, the time-travel game, Triad.
* **Text input.** A line with editing, any-key waits, typed words judged in
a window, and a `console` look (wrap by font, cursor, fixed width).
Needed by: the text adventure, the typing edition.
* **Gamepads and rumble.** Analog sticks into dx and dy with a dead zone, a
rumble action (engine: `controller*`, `controllerDoRumble`). Needed by:
the Captain Power and Video Driver titles, any action game.
* **An interactive GUI.** Click and change events from the hud document,
show and hide actions, two-way binding (engine: `guiSetHandler`,
`guiGetValue`, `guiShow`, `guiHide`). This is also how the options
screen, name entry, and menus above get drawn.
* **The gun model, completed.** Per-gun ammo, reload by offscreen, border,
or button, bullet on press or release, fire delay, random jam, pickups,
second-chance windows, hit classes (enemy, civilian, bonus) with rules,
shots judged on the next frame. Needed by: the 25 gun-game titles.
**Picture and sound**
* **HUD widgets bound to vars.** Digits from a sprite font, icons for
lives, a bar, an ammo row, blinking text, a scrolling ticker, gauges, and
an animated move prompt that shows the judge. Needed by: every ported
family (the Picture row); every original game.
* **Disc control.** Speed changes, holding the disc for a delay, several
videos in one game, audio tracks, subtitles, and the picture effects
(engine: `discChangeSpeed`, `discSetAudioTrack`, `srt*`, `vldp*`).
Needed by: the typing edition, Cops, the language titles.
* **Colour reads from the picture.** `pictureAt` reads brightness; a
colour or YUV read and a focus area (engine: `vldpGetPixel`,
`vldpFocusArea`). Needed by: Captain Power, Video Driver.
* **Video outside the disc.** A video look for the overlay or a 3D surface,
and a play-video action that waits (engine: `video*`,
`materialSetVideo`).
* **Sound control and positional audio.** Stop, volume, pause, fade; a
positional sound on an entity (engine: `soundStop`, `soundSetVolume`,
`soundSetNode`, `soundSetRange`, `soundSetListener`, `musicPause`). A
clip cannot be stopped today.
* **Music playlists.** Several tracks switched by state, volume, a
user-editable list. Needed by: Starship Troopers, Gallagher's Gallery,
Space Rocks.
* **Vector drawing.** Line, circle, ellipse, and polygon looks for
overlay-only games (engine: `overlayCircle`, `overlayEllipse`,
`overlayPlot`). Needed by: Space Rocks, Minesweeper.
* **Tweens.** A tween action over any field or var with an easing curve
and a duration, that can wait (engine: `tweenValue`, `EASE_*`).
* **Particles with an appearance.** Texture, frames, blend, trail,
collision, spin, direction, and drag, plus start and stop (engine:
`emitterSet*`). Today they are coloured dots.
**3D and physics** (engine features Forge does not reach; no ported game
needs them, every original 3D game would): materials (normal, emissive,
tiling, blend, double-sided) and a set-material action; labels over 3D
entities (`sceneProject`); bloom, tonemap, environment, antialias, and
shadow quality on the scene layer; several views (split screen, mirrors);
animation layers with masks and weights, pause, and morphs; terrain from a
heightmap; wander and line-of-sight for AI (`navRandomPoint`,
`navRaycast`); velocity, enable, conveyors, hinge limits, pinned cloth,
swim and slope on the character, gears and brakes on the vehicle.
**MIDI.** A MIDI layer that opens the input port (the `midi` event needs
Lua to open it today) and a note action for output.
**The editor.** A canvas at the game's own size (ActionMax's is 360x240);
play from here with the editor camera; orbiting by pointer; navmesh baking
as an editor action; the chooser drawn by the RmlUi document. All were
planned (sections 1-3) and not built.
### 15.6 The titles that still need their own Lua, and what absorbs it
* **Dragon's Lair Enhanced:** per-level constants (overrides), one of three
fixed orders drawn at random (random choice, progression), a bonus for 24
named moves (per-move overrides).
* **Space Ace Enhanced:** a random game-over clip and extra death clip
(random choice), the next level by what is beaten, the difficulty, and a
coin flip (progression), move rows gated on difficulty and beaten levels
(settings gating, progression conditions).
* **Aurunmilla:** one draw per game picking some moves' death clips
(random choice, drawn once per game), per-level move score (overrides).
* **Starship Troopers:** a real map with skip-beaten and a bonus stage
after four (map, progression), map music and two tracks (playlists).
* **Brain Dead 13:** seven collectible flags set by moves and saved per
slot, which pick an alternate final level (vars on moves, save slots with
the game's vars, progression); random death clips by range and an extra
death by level (random choice, overrides); a random alternate scene.
* **Ninja Hayate 1080:** a random finishing bonus by difficulty, settings
that force or gate other settings, extra clips gated on a setting (random
choice, settings gating).
* **Sintel:** no logic of its own (empty add-ons, 83 plain move rows). Why
it needs its Lua is undiagnosed; the likely cause is a value the
converter does not lift, not a mechanic. First to look at.
* **The map-mode titles** run their `main.singe` data functions every play
because their moves are drawn at random (random choice over move rows),
and their per-death clip mappings are 2,000 lines of code (deaths as
data).
* **The gun games** (26 vocabularies, 54,627 lines): the gun model, hit
classes, menus over video, counters, money instead of lives, puzzles as
choices, two players. The puzzles (roulette, cards, a safe dial) are the
last mile, and each is a game's own vocabulary under 15.1's rule 4.
* **The text adventure:** text input and the console look; its parser's
ordered rules that may re-enter are the one thing Forge's parser layer
lacks (14.14).
* **The time-travel game:** shuffled periods (random choice), deaths by
cause (deaths as data), reversal to a mark (disc control), a map, a trader
and a slot machine (menus, counters).
The titles not ported at all (Future Boy, Road Blaster, Cracke, Triad, the
Singe 1 monoliths, and the overlay programs such as Cops, Fast Draw,
Gallagher's Gallery, Captain Power, Video Driver, Minesweeper, and Space
Rocks) need the same list plus gamepads, rumble, colour reads, vector
drawing, and speed changes.
### 15.7 Found while surveying, checked
* **A game's colour calls do nothing.** The shim's helper table defines
`colorForeground` twice (Author.singe 7562 and 7656); in a table
constructor the later wins, so the no-op replaces the real one. A defect.
* **Publish cannot be reached from the keyboard.** Forge's `onKeyPressed`
sends P to `forgePlay` whenever no field or picker is open (Forge.singe
5096), so `forgeKey`'s P for `forgePublish` (4165) never runs. The book
says both. A defect.
* **The loops never clear what a game draws.** A game's own Lua draws
through `Q.queueDraw`; only the game vocabularies' looks call
`authorFlushDraws` or `authorDropDraws`, which Author.singe defines and
never calls, so under the five loops the queue grows by every draw call a
game's Lua makes, every frame. A defect, and the reason nothing a game
draws is seen.
* **The book is stale about the light sensor:** it says the sensor lives in
the ActionMax games' own vocabulary, not Forge's; it is core now.
* **Quirks copied faithfully, not defects:** the Kimmy loop's hold decay
can never run (its test is always true), the map-mode Super Don Quixote
loop plays a one-frame get-ready clip on one path, and a choice menu
handles only two to four options. Each was checked against the original
framework, which does the same; a port must, to agree. In Forge's own
model they become the judges' presets and are free to be right.
* **Checked and not true:** that Kimmy's scorer takes its arguments in a
different order from the KarisFramework loop's.
### 15.8 Order of work, and how each step is proved
The measure for every step that touches the loops is the port sweep: every
title that agrees before the step agrees after it, on the same binary. The
baseline is the sweep run on 2026-09-26 after 14.19 (recorded there when it
finishes). A step that changes nothing a player sees must change no trace.
1. **The three defects in 15.7.** Independent of the rest.
2. **Names only.** The retirements of 15.3 inside the runtime, the help,
the book, and the vocabulary files; `kind` values in descriptions
regenerated by the converter. No behaviour changes; the sweep must not
move.
3. **Versions to options.** Every check of a framework, version, or title
becomes an option the converter writes (15.4's table). The sweep must
not move.
4. **One loop.** Fold the loops into the one `qte` behaviour a piece at a
time, starting with the screens that are near-copies in all five
(continue, game over, high score, difficulty select, level setup, scene
done, death clip setup), then the judges, then the level loop. The
sweep after each piece.
5. **Forge's data model.** The converter writes 15.4's model instead of the
globals dump; the loop reads it. The sweep.
6. **Generic features**, in the order that unblocks the most: languages,
random choice and progression and overrides (these retire most of the
hosted Lua), settings and the service mode, HUD widgets (the Picture
row), boards with name entry, save slots, the judge vocabulary for any
game, menus and maps, then the rest of 15.5. Each with a test scene and
a Forge tutorial or recipe, since a feature only ports can reach is not
generic.
7. **Retire the hosted Lua**, title by title (15.6), each proved by the
sweep and by `util/forgeStandalone.py`.
8. **The converter renamed and its output checked** for any third-party
name, which becomes a check in the sweep.
### Out of scope, stated so it is not assumed
Binary compatibility with the frameworks' own save and config files.
Keeping `kind = "kimmy"` and the other retired names working in old
descriptions (the converter regenerates them, and section 3's rule stands:
nothing has to be backward compatible). The completeness rows of 14.18
other than State, until the features in 15.5 that they need exist.
### 15.9 Steps 1 to 3, done (2026-09-27)
**Step 1, the defects** (15.7): a game's colour calls, Publish (now Shift+P;
P stays Play), and the draw queue the five loops never emptied. Done the day
before, with scene 56 covering the export.
**Step 2, names.** The four framework-named behaviours are `qteHeld` (was
`kimmy`), `qteSegments` (`rdg`), `qteCheckpoints` (`timegal`), and
`qteTrophies` (`sdq`) until step 4 folds them into `qte`; the constants
table `KIMMY` and its kin are `HELD`, `HELD_MASK`, `HELD_BIT`, `HELD_WORD`,
and `HELD_SIDE`; the trophy loop's helpers are `trophyWrong`, `trophyPath`,
`trophyAfterDeath`, and `trophyStartScene`; `RDG_PAIRS` is `PAIR_SWITCHES`;
the runtime's seed global is `AUTHOR_SEED` (the test driver sets it). Every
help string, section header, and comment in Author.singe now says what a
thing does rather than whose it was, the Forge book's paragraph on the ports
does the same (and no longer says the light sensor is not Forge's), and the
converter's header lines name no framework. The 18 descriptions that used
the old kinds were changed in place, `kind` strings only. What is left in
the descriptions is credits (an original author named as one) and players'
initials on high-score boards, which are data.
**Step 3, versions to options.** `Q.karis332` and `Q.mazescater` are gone,
and nothing reads `qte.framework`. In their place `Q.option(q, name,
default)` reads the description's `qte.options`: `gameOverAfterContinue`
("playOn" or "seek"), `secretInputs` (the switches held for the hidden level),
`hintSeconds`, `alternateMashRate`, `mashAnySwitch`, and `doubleKind`. The
defaults are the behaviour a description without the option always had. The
converter writes the options from what it detects and no longer writes the
version; the existing descriptions were migrated (11 got the seek-and-secret
pair, one the compound-move four, the rest lost a line nothing read), and a
converter run on three of them reproduces them.
Proved by comparison on the frozen binary: one title of each behaviour and a
gun game, capped at 15,000 frames, after each step, and Linea (the compound
options) and Arcade Xperience 2 (the seeking game over) uncapped after step 3;
all agree. The behaviour flags `q.kimmy`, `q.rdg`, `q.timegal`, and `q.sdq`
remain: they say which loop is running, and step 4 retires them with the
loops.
**Found on the way: test runs had written to five titles' saved boards.**
Aurunmilla, badlands, Survival, and Mazinga had a player called MOR in their
`hscore.cfg` that the originals' zips do not have (Sugar Rush's zip does, so
there it is real), and the descriptions of those four had been generated from
the written boards. The boards are back to the zips' (the written ones are in
`~/claude/portwork/polluted-boards`), the four descriptions were regenerated
from them, and all four agree uncapped. Aurunmilla's converter output also
changes from run to run, because its script draws `math.random(2)` as it
loads; its description is whatever the last run drew, and it agrees.
### 15.10 Step 4, one loop: the styles and the shared screens (2026-09-27)
**4a, one behaviour.** The four named behaviours are gone: `qte` is the
only one, and `qte.options.style` picks how it plays: `windows` (the
default, moves answered in a window of frames), `held` (moves judged by what
is held), `segments`, `checkpoints`, or `trophies`. `QTE_STYLES` holds the
five, each with its help and its attach; an unknown style is an error that
names the ones there are. `q.qteStyle` replaced the four behaviour flags.
**4b, the shared screens.** The held style kept its own copy of most of the
windows style's screens. Each pair is now one function, and every real
difference between them became an option in `qte.options` with the style's
own default, so a description can ask for either behaviour whatever its
style:
| Option | What it does | Held default |
|---|---|---|
| `gameOverAfterContinue` | `"playOn"` from the continue clip when it adjoins, or `"seek"` | `"seek"` |
| `rankingsAfterBoard` | the same choice for the rankings after the high score board | `"seek"` |
| `levelSelect` | game types 2 and 4, and play style 4, choose their level | on |
| `deathCountGameType` | the game type that counts deaths: no life lost, no points, fewest ranked, the total kept across levels | 4 |
| `noGameOverGameType` | the game type that goes from the board back to the attract | 2 |
| `saveIcon` | a disk shows where the game would save | on |
| `managedHud` | the loop sets the score, lives, level, and record by the display setting through play, the get-ready, and a death, with a pause on the hints frame after a death on display 2 | on |
| `barBonusRestarts` | the count to the life bar's bonus starts over at the bonus even with the bar full | on |
| `hintedDeathCounts` | a death after the hints counts as the wrong move it was (the scene bonus reads the count) | on |
| `pendingIsUnanswered` | a move still pending as its window closes counts as unanswered | on |
| `wayOutPlaysTarget` | a way out plays the scene it names, done or not; off, it goes there if still to do and past it if done | on |
| `extraDeathClipSkips` | BUTTON1 cuts the extra clip after a death short | on |
| `waitForWindowStart` | a window that ends before it starts waits for its start, instead of closing at its end | on |
| `outEndsSceneNow` | the out jump ends the scene in the same step, instead of the next | on |
`Q.option` reads the description's options, then the style's defaults
(`q.optionDefaults`), then the caller's. Where a shared screen must call the
style's own start or frame setup, it goes through `q.qteFns`: `Q` for the
windows style, and for the held style a table that gives its own function
where it has one and the shared one otherwise.
**4c, the level loop.** The two level loops are one, `Q.doLevel`. What
is each style's own comes through `q.qteFns`: the judge (`Q.judge`, or the
held style's move loop, now `K.judge`), the verdict on a wrong move
(`Q.failed` or `K.failed`), and the resets of the judging state at a scene's
start and after a right move. Tilt (`Q.checkTilt`), the next-move marker,
two players' turns, and the scene and level jumps need no option: they act
only on dips, settings, and move kinds the held descriptions have, and read
through `q`, where the windows style has none of them. A move whose death
is -3 passes as -1 does in both: no windows description has one. Two more
differences surfaced only when the traces were compared, and are options: a
window that ends before it starts (a windows title has one) closes at its end
in the windows style and waits for its start in the held one
(`waitForWindowStart`), and the out jump ends the scene in the same step in
the held style and the next in the windows one (`outEndsSceneNow`). A scan
of the descriptions had said no held title used the out jump; the held
titles' paths are built by their hosted Lua, where a scan of the data cannot
see, so the traces are the check, not the scan.
Merged in 4b: enter scene, scene done, continue, game over, difficulty select,
death clip setup, after death (with the resume and the scene-end test it
used), the skip and let-go judges, level setup, next level, on to the next
level, level replay, the high score board, the scorer, the board test, the
start of a game, and the record shown (`Q.topFor`). The held style's local
copies of the lives setup and the level-select jump are shared as
`Q.livesForType` and `Q.toLevelSelect`. About 550 lines are gone.
Two held names were a game's name and are not any more: `q.hayate` is
`q.arcadeEdition` (the later engine's arcade mode, from its settings), and the
comment that named the title says what the edition is. One latent fault went
with the merge: the held attach put its scorer on `q`, where a game's own Lua
calling `addPoints(points, value)` would have reached it without `q`; no
description did, and the shim's helper now answers it for every style.
Where the two copies differed in a way no option should keep, the shared one
takes the more capable form, each checked against the library first: the
game's `swapDeath` hook now works in every style (the windows copy ignored
what it chose; no description defines it); `specialScore` sets the points
in every style (the held copy wiped them; none defines it); the end-of-clip
tests are `>=` everywhere (the frame is read after the seek); the right and
wrong move counts start over with every new game (the windows copy skipped
game type 3, where nothing reads them); a level replay setting below -1
draws the order again as the held copy did (no description has one).
What is left of the held style is its own: the judges that test the mask of
what is held, its move loop, level loop, attract, saves, new-game menu, level
select, clear, finish (the percent made, where the windows finish counts a
bonus down), and quit menu. The judges and the level loop are the next
pieces.
Proved by comparison on the frozen binary, to 20,000 frames:
- 4a and 4b: the full port sweep (81 pairs) after the screen merges; 74
agree and none differ. The other seven were the segmented titles in the
library's own layout, which `sweep.sh` had sent to the rdg family (it knows
only the Hypseus layout, so the runs found no video); `sweep.sh` now sends
them to the karis driver, and they are rerun below. One held default was
missing from the swept code (`barBonusRestarts`); no held title plays game
type 1, the only one it touches, so no trace could show it, and it is in.
- 4c: run from a separate tree with its own copy of the library's small
files, while the sweep held `forge/`: all seven held titles, and windows
titles with way-outs, out jumps, death -2 moves, an inverted window, game
types 1 and 2, and the sought game over (20 pairs); all agree. The first
pass had two differ, which found `waitForWindowStart` and
`outEndsSceneNow`.
- The seven segmented titles, rerun under the karis driver on the code with
4c in: all agree.
- The full sweep on the code with 4c in (81 pairs): 80 agree and none
differ. The 81st, the library's plain Crime Patrol, agrees for the 321
trace lines both runs reach, and then neither goes on: the original sits
there until the hour's limit stops it, and the port spun at full CPU, did
not quit on the SIGTERM the limit sends, and ran 16 hours before it was
killed. It was the open item of 14.19, and it is closed: the original
loops there too. The game's own script picks the rookie's next nag
(getRookieNags, with the Undertaker dip on) by drawing 1 to 5 until it finds
one not yet used, and starts over only when 1 to 6 are all used; nothing
sets the sixth, so the sixth time through it draws forever. The original's
performance line shows a 275-second frame. The port runs the same code and
hangs at the same place, which is what "plays as the original" asks. Four
of the library's five copies of the game have it (the first-generation one
draws differently). The engine's new script watchdog stops both runs cleanly
on a SIGTERM and logs where they were; the old binary had ended the
original only because timeout's two SIGTERMs arrived as two. `compare.sh` now kills a run 30 seconds
after the SIGTERM (`timeout -k`), so one hung title cannot stall a sweep;
the engine only sets a flag on SIGTERM, which a script that never returns
never lets it read.
**4d, the segmented lineage's screens.** The segments style (and the
trophies style, which builds on it) had its own game over and continue. Both
fold into the shared ones. The lineage gets its own `q.qteFns` (`SEG_FNS`:
its start, the board test through its game's own Lua, and a step back over
segments rather than scenes), and its defaults:
| Option | What it does | Default for the lineage |
|---|---|---|
| `clipEndExact` | a clip ends exactly at its last frame, not at or past it (`Q.clipEnded`, which replaces the lineage's own test) | on for the first copies and trophies, off for the later copies |
| `diffChoiceAfterGameOver` | the next game offers the difficulty again after a game over, even when a continue turned it off | off |
| `continueDeclines` | BUTTON2 turns the continue down | off |
`gameOverAfterContinue` is "seek" for the lineage, as the held style has
it. Checked on the frozen binary to 20,000 frames after each of the two
merges: the six segments titles in the library's own layout, the trophies
title, the three segments titles and the checkpoints one from the Hypseus
library, and TimeGal, FBC, and Astroboy of the others; eleven of the
fourteen reach a game over three times and twelve a continue, so the screens
that changed were played. After the game over: all fourteen agree. After
the continue: all fourteen agree.
**4e, the judges.** The trophies style's let-go and mash judges were the
shared ones reading its constants from `q` (the same numbers); they are
merged, with `mashClearsButton` (on; off for trophies: the press that ends
a mash is not used up). The rest are not copies: the windows judge answers
presses in a window of frames, the held judge tests a mask of what is held,
and the segmented lineage numbers its inputs its own way (its skip accepts only
the play switches, and its first copies a third button of their own), so each
style keeps its own judge, reached through `q.qteFns`. The three segmented
level loops (segments, checkpoints, trophies) are a different machine from the
shared one (a fifth to a quarter alike), not copies, and fold only when step 5
gives every style one data model to play. Checked: the trophies title and
TimeGal, Tron, and Danmachi of the windows style agree.
**Names out of Forge's comments.** A scan of Author.singe against every
title in the library found game names in nine comments (and the old
behaviour names in two); all are reworded to say what the thing does. The
trophies style's level order was kept under a data key named for a game; it
is `trophyOrder` in the runtime, the converter, and the one description that
has it, which agrees on a rerun.
### 15.11 Step 5, Forge's data model: one source for each (2026-09-28)
The first slice of step 5 is the one 15.4 names last: a fact written in one
place. Measured first: across the 49 QTE descriptions, 467 dips were in both
`settings` and `dips` (the held titles disagreed on up to 15 of them; the
runtime lays `dips` over `settings`, so the other copy was dead data that
looked meaningful), and 38 descriptions carried their levels twice, as
`settings.Level` rows and as `levels`.
- **Reproducible conversion first.** The converter now seeds Lua's random
generator (`CONVERT_SEED`), so a game that draws as it loads (a scene swap,
a level's variant) is converted the same every time. Before the change,
every description was regenerated and compared with the library's as data:
46 of 49 identical; BD13 and Esh Aurunmilla differed by their random
draws, and hayate 1080 by a high-score board the description had taken from
a test run's polluted board (restored from the zip since, but the
description had kept the old score). Those three were replaced by the
seeded converter's output, and all three agree.
- **Levels in one place.** `levels` is the only home of a level's fields,
every row the game declares included (a row past the last stage, a secret
row a game cannot reach), and the eighth column the held style reads (the
level select's still) is `selectStill`. The runtime builds the loops'
positional rows from `levels` (`Q.applySnapshot`, which both attaches now
share, with `QTE.SELECTSTILL` named); `settings.Level` is gone. A check
over all 49 found the rows built from the new `levels` equal to the old
`settings.Level` row for row.
- **Dips in one place.** A setting `dips` also has is left out of
`settings`. Every removal was checked to be one `dips` carries.
Found on the way: the engine wrote a log line's text and its newline in two
calls, so a line from another thread (the video thread's) could land inside
it, which made one comparison's trace look different; a line is now written
whole.
Checked: Astroboy and Arcade Xperience 2 (added rows), FBC (the level
select's still), the trophies title, Jeeg, and TimeGal agree. The full
sweep (81 pairs, to 20,000 frames, on the frozen binary): all 81 agree,
Crime Patrol for the 321 trace lines both halves reach before the game's own
nag loop (15.10); its port half was ended at 33 minutes, the result being
known.
**Forge's names for the data (prepared, to go in after the sweep above).**
Three more slices, each proved before any run by the runtime's own view:
`QTE_CLIPS`, `QTE_FIELDS`, the constant tables, and `Q.applySnapshot` taken
from Author.singe, each old and new description laid down on an empty state
as its style's attach lays it, and the two states compared field by field.
- **Clips and stills.** The game's `offsetX`/`offsetXend` globals are
`clips` under Forge's names (`title`, `gameIntro`, `getReady`,
`resurrect`, `extraDeath`, `continue`, `gameOver`, `gameOverAlt`,
`clear`, `newHighScore`, `enterName`, `rankings`, `quit`, and `attract`,
a list of the attract steps); its `frameX` globals are `stills` (`hints`,
`rankings`, the difficulty stills, and the rest, each named by what
follows "frame"). The names are written once, in Author.singe's
`QTE_CLIPS`; the converter reads that table from there.
- **Named fields.** `scoring` (move, perDifficulty, scene, deathPenalty,
level, noDeathBonus, game, secret, extraLifeEvery), `lifeBar` (size,
missCost, regainAfter), `windowDelay` (normal, hard, extreme), `order`
(play, levels, tiers, mapStart), `deaths` (clips, and randomCount: how
many of them a random death draws from, which in 16 descriptions is not
all of them), `secret` (allowed), `video` (fps, relativeFrames);
Author.singe's `QTE_FIELDS` maps each to the global the loops read.
- **No copies of constants.** A setting equal to the constant the runtime
lays down first (the QTE table, and HELD over it for a held game) is left
out; and the segmented lineage's descriptions carry no settings at all,
since its runtime reads none (its game's own Lua declares them).
The 38 windows and held descriptions give the runtime the same state after
each slice; the 11 segmented ones differ only by the settings their runtime
never reads. The settings across the 49 went from 26,289 keys to 9,806.
## 16. TimeGal, complete (started 2026-09-28)
The owner, on a week of step 4 and 5 work: "It just seems like we're
spending a lot of time and not really getting much closer to actual,
complete, ports of anything." Fair: every sweep re-proved the State row
of 14.18 and nothing moved the other six. So one title goes through every
row, each with a check, before anything general: TimeGal (the windows
style, plays with none of its game's Lua, State agreeing).
### 16.1 Picture
**The checks.** `karisDrive.singe` gains `PORT_SHOTS=N` (a numbered
screenshot at each change the trace prints and one a frame into each
move's window; the traces agree, so a number is the same moment in both)
and `PORT_HUD=1` (the HUD's flags as letters on each trace line: S score,
L lives, V level, T record, C credits, A action, K skip, G get-ready, H
choices, D LCD, P pause, 1-4 disks). `testScripts/ports/shotDiff.py` pairs
the screenshots by number and reports how much of each differs, with a side
by side and the differing pixels marked for a person to look at;
`compare.sh` now gathers screenshots from wherever the engine saves them.
**What the checks found.** The port drew nothing of the game: no score, no
lives, no record, and no time-stop menu, so a player could not see what the
choice was between (the trace agreed because the drive does not look). And
the port's HUD flags differed from the original's almost everywhere: the
windows loop set them by a simpler rule than every framework copy does.
**What changed.**
- The HUD is a look, `qteHud` (14.15's design), drawing from the loop's
state what the flags say, in the games' order, from the game's own
pictures (`Overlay/`, `Overlay/Lores/` at the lower resolution) and font,
laid out as the games lay it out: the score in the digit pictures, the
record, the lives or the life bar, the credits (FREE PLAY, INSERT COIN, or
the count, blinking), the level name, get-ready, skip, the choice menu,
pause, and the move prompt (a direction's arrow sliding out from the
middle, a button's picture, or the hint pictures by `dip_ShowAction`).
It draws nothing until the game runs, as the games draw nothing during
the title clip. It changes no state and draws no random number: where
the games' drawing had a side effect (the level name clearing once play
passes the first move), the loop does it, at the end of its step. The
converter gives a qte game this look.
- The windows style's `managedHud` default is on: all 21 framework copies
that could be found set the HUD by the display setting.
- The windows attach turns `ShowTop`, `ShowLCD`, and `ShowLevel` from 1 and
0 into true and false, as the games' config reader does (a 0 is true in
Lua, so a switched-off record would have shown), and the start sets the
record's flag as the games' `readConfig` does.
- Forge's own font file is named once (`AUTHOR_FONT_FILE`).
**Where it stands** (the frozen binary, the scripted run to 20,000 frames:
the attract loop, play, the prompts, the time-stop menu, get-ready, deaths,
five continues, three game overs): the trace with the HUD flags agrees on
all 226 lines; 247 of 250 screenshots are identical to the original's.
The other 3 are the FREE PLAY blink: the games blink by CPU time, the
port by the engine's clock, from when a picture appears, so after minutes
of play the phases part. Not yet reached, so not yet checked: the high
score entry, the level clear and finish screens, pause, and the service
screens (16.2).
### 16.2 Sound
`PORT_SOUND=1` puts each sound played or stopped (by its file's name) and
each change to the disc's audio on the trace line, the engine's calls
wrapped before the game loads so the original's and the port's are caught
the same way. The run of 16.1 plays 31 sounds (one coin, twenty right,
ten wrong), the same files at the same frames in both; the perfect run
below plays every sound it reaches the same. Not reached, so not checked:
the victory and clear sounds of a run that finishes badly.
### 16.3 Screens and persistence
**The drives.** `PORT_WRONG_EVERY` and `PORT_MISS_EVERY` set high make a
perfect run: every level, the level clears, the finish, the high score
screen, the game over. `PORT_ATTRACT=N` lets the attract loop run N frames
before the coin and START, so its rankings and best-percent screens play.
`PORT_SERVICE=1` walks the three service screens from the attract loop:
the options' first row on to the graphics screen, every row changed once and
"Default", on to the performance screen, the same, on to the options, the
same, and EXIT; the trace carries each screen's row and every setting.
`compare.sh` gains `PORT_TWICE=1`, each side run twice in the same data
directory (the original's own files put back only after both), so the
second run shows what the first saved.
**What was missing, and is now there.**
- *Pause:* TimeGal pauses only through its title clip, where no HUD draws;
nothing to build.
- *The autosave disk* after a level clear, two seconds, as the games time
it (on the shared timer and altState, from the frame after it is set);
`saveIcon` is on for the windows style too.
- *The boards.* The description carries all three (`boards.normal`,
`lifeBar`, `survivor`) and the best percent of each level
(`boards.percent`), from the game's hscore.cfg; the record shown and the
board a result goes on are the game type's; a result goes on above the
first entry it equals or beats, and a name left empty is drawn from the
games' ten names at random (the same draw, so what follows stays in step).
The boards are kept through the engine's save store and read at attach.
- *Name entry:* the 44-cell grid, CLR and END, the joystick's quarter
second, the sounds, drawn with the cursor blinking; `nameEntry` is on for
the windows style and off for the held one until its framework is read.
- *The board screens:* the game type's board after a result, the three
boards side by side on the attract loop's rankings, and the best percent.
- *The service screens:* the options (game type, difficulty, play style,
start level and scene, rewind, coins, lives, continues, prompts, hints,
mash), the graphics screen (extra clips, titles, LCD, level, record,
where the score, lives, and coins go, the font, its size, the two
colours), and the performance screen (the machine model and what it sets,
resolution, display, scanlines, score and lives drawn or rendered, font
quality), one loop and one printer driven by a table per screen, the
values changed by the games' own rules (the ties between them, and the
asymmetries: hints the kid's type keeps going on, a colour stepping past
the other to 13, the third model's resolution set differently each way).
"Default" restores the game's `default.cfg`, which the description now
carries as `defaults`; leaving writes the settings (`Q.configSave`).
- *The start* (`Q.initJob`): what the games' `initJob` does again after the
service screens, which the port had done only once: the attract loop from
its first state, the counters cleared, the HUD back to the credits, the
extra clips, language, and record read from the settings, and the overlay
laid out again (`Q.applyResolution`, which also says whether the
resolution has scanlines, sticky once found, as the games' flag is).
- The windows switch handler opens and closes the service screens on
SERVICE and takes no coins while they show.
**Where it stands** (the frozen binary):
- The perfect run (60,000 frames): 813 trace lines agree (state, HUD flags,
sounds); 905 of 909 screenshots identical, the other four a blink phase
(FREE PLAY three times, the name cursor once).
- The attract loop run twice through (20,000 frames): 41 lines agree; 21 of
25 screenshots identical, the other four FREE PLAY.
- The service walk and the play after it (9,000 frames): 259 lines agree;
185 of 187 screenshots identical, the other two FREE PLAY.
- Persistence, the board: each side run twice (80,000 frames each, the
attract loop, then a perfect run): 1,679 lines agree, and the second run's
rankings show the first run's score in both; every screenshot pair that
differs is a blink.
**Not yet:** the save-slot screen, the exit menu, SERVICE from play (the
games autosave and come back to the level; the port starts a new game),
the in-game difficulty screen, the LCD ticker of the attract loop (off for
TimeGal), and a check that the settings survive a restart (running). The
"Show Moves" setting has a value the games print as "Cliff" (a prompt in
that game's style), which names a game in Forge's code; the owner decides.
## 17. Forge can reproduce every game (proposed 2026-09-28)
**The goal changes.** Sections 14 and 16 set out to make each port play
exactly as its original, checked picture by picture. That meant writing
each framework's behaviour into Forge again, one version at a time, and
every check found another corner (16.1 to 16.3). It does not converge, and
it is the opposite of Forge as its own thing (15.1). The goal is instead:
**anything a game in the library does, a developer can do in Forge**, so a
developer on another framework has no reason not to use Forge. A converted
title is the evidence that Forge can reproduce it, not a replica to
maintain.
**What "can reproduce" means.** For each thing a game in the library does
(a judge, a screen, a HUD element, a setting, a kind of persistence, a
control), Forge has a capability that does it, described in the vocabulary
and the manual, with its behaviour set by data and options rather than by
which framework it came from. The look is the developer's: pictures,
positions, fonts, and colours are data on HUD widgets and screens (15.5),
so a developer can make it look like their game did, but Forge ships no
copy of anyone's layout code.
**The ledger.** One table, generated from the library and not written by
hand: every capability the library's games use, which titles use it, and
its state in Forge (built, partial, missing). The converter produces it:
as it maps a title onto Forge's model (15.4) it names each thing it maps
and each thing it cannot, and the ledger is the union. Today's move kinds
alone: press, chord, diagonal, action-plus-direction, mash (one input,
two, run), hold (four directions and a button), letGo, double, loop,
multi/count, choose, path, yesNo, timed, way, wayOut, skip, and the compound
kinds of 3.31c (Linea); most titles use a dozen or more of them.
**The checks, per capability, not per title.**
1. *It exists:* a Forge test scene exercises it (as scenes 52 to 57 do
now), and the vocabulary and manual describe it.
2. *It is enough:* at least one converted title that uses it plays the
same state trace as its original (the State row of 14.18, which stays;
it checks the data and the judging, not the picture).
3. *It is reachable:* a developer can author it from the editor or a
description without Lua.
A title is **reproducible** when the converter maps everything it uses,
nothing unmapped, and its state trace agrees. Picture, Screens, and Sound
(14.18) become "every element the title shows or plays is a capability in
the ledger", not pixel equality.
**What happens to the current code.** The five loops' styles (windows,
held, segments, checkpoints, trophies) become option sets over one model,
as 15.4 already planned; what was written to match one framework's
pixels (the service screen's exact rows, blink phases, the copied default
font, per-framework layout constants) is retired or turned into widget and
screen data. The switches, saves, pause, and prompt work of 16.3 stays as
capabilities (save slots, quick save, exit menu, pause during the title).
**Order of work.**
1. The converter reports what it maps and what it cannot, per title; the
ledger is generated from that over the whole library.
2. The ledger is sorted by how many titles each missing or partial
capability blocks; work goes down that list, each capability through
the three checks above.
3. Each style's framework-specific code is replaced by the generic
capability as its turn comes, and removed when no title needs it.
4. Progress is reported as the ledger: capabilities built, titles
reproducible, and what blocks the rest.
### 17.1 Step 1, the ledger (2026-09-28)
`util/forgePortKaris.lua` with `FORGE_LEDGER=<file>` writes, instead of the
description, one line for each thing it maps: **forge** (a field of Forge's
model), **framework** (played under the framework's names or numbers, or a
style), or **lua** (the game's own Lua still does it). `util/forgeLedger.py`
runs it over every description the converter made and writes
`testScripts/ports/ledger.md`. It takes about a second and plays nothing.
Hooks in the add-ons file most titles ship unchanged (18 copies) count as
framework; `swapScene` counts as forge, since the converter runs it into
`scene.swaps`.
**What it shows (76 titles):**
* *In Forge's model already*, for the 38 windows and held titles: the
screens and stills, scoring, the life bar, window delays, level order,
deaths, boards, saves, and scene swaps. The other 38 have almost nothing
in the model: their mechanics are Lua.
* *Lua:* 25 gun games, the time-travel game, and the text adventure play
through a hand-written `Vocabulary.singe`; 11 segmented titles run their
own level functions; 12 titles have add-on hooks of their own
(`startConf` and `swapLevel` 7, `doLevelSelect` and `moveCursor` 6,
`swapDeath` 5, `specialScore` 4, `loadSavePlus` and `writeSavePlus` 3,
and six of Chantze's Stone's own).
* *Framework:* 21 judges, every move row still in the framework's
numbering (Linea and Arcade Xperience 2 use compound kinds Forge has no
name for, named in their frameworks like sequences: UL, DRU, LDB); 69
settings under `dip_` names; 455 globals nothing names, most of them the
framework's own state flags and picture numbers rather than game data.
**What it does not cover yet:** the five ActionMax titles (their converter
is `util/forgePortActionMax.py`); the gun games' and segmented titles'
mechanics are one "vocabulary" or "levelFunctions" line each, not itemized;
Picture and Sound capabilities (prompts, gauges, HUD elements) are not in
the ledger; "runs without its Lua" is `standalone.txt` as proved on
2026-09-24, before today's regeneration.
**Order of work, by titles blocked:**
1. The gun model (15.5): 25 titles. First itemize what the vocabularies
do, so the ledger lists the gun capabilities rather than one line.
2. The segmented lineage's level functions as data: 11 titles.
3. Move rows under Forge's judge names instead of framework numbers: 38
titles, mostly converter work; the compound kinds get a sequence judge.
4. Settings (15.5): the 69 `dip_` names as declared, named settings: 75
titles.
5. The globals: drop what is the framework's own state (checked by the
State trace), name what is data: 38 titles.
6. The remaining hooks, most-used first.
**The text adventure (Rollercoaster).** Not a priority of its own (owner,
2026-09-28), but it fits the Sierra model of section 6: its rooms, exits,
disc stills and clips, two-word parser, objects, furniture, room scenes, and
the bomb's turn count are rooms, `said` rules, the inventory, sequences, a
counter, and `die`. Two capabilities are missing, and both belong to the
Sierra lineage generally: a console look (a scrolling text window with
wrapping and a cursor, 15.5) and a question a typed answer satisfies (the
name, the prize, "put it where?"). With those, it becomes rooms and rules
instead of a vocabulary; until then it stays as it is.
### 17.2 What each type of game needs (surveyed 2026-09-28)
Four read-only surveys: the 2D gun games (the 18 distinct vocabularies),
the QTE lineages still in Lua with ActionMax and the time-travel game, 2D
and 3D platformers, and 3D light-gun games. The last two have no titles in
the library, so they are measured against the genres. Each item was checked
against `forge/Author.singe` and `docs/ForgeVocabulary.adoc`; "unchecked"
marks what no scene proves.
**Wrong today (checked in the code):**
* A 3D shot passes through anything that is not a target, up to four
bodies (`fire`, Author.singe ~4524): walls and cover never stop a shot.
Meant for the player's own body and glass; should skip only those, and
triggers, and a `seeThrough` flag.
* A 3D target's zones: the last zone within reach wins, not the nearest.
* The QTE loop never calls `writeSavePlus`, so collectibles saved per slot
(DL2e, BD13) are not kept (reported, unchecked at play).
* ActionMax's sensor is in every room, so the gunshot sounds on the title,
menu, and game-over screens; the original sounds it in play only
(reported, unchecked at play). A converter fix.
* The QTE's held, segments, checkpoints, and trophies titles draw no HUD
or prompts at all (`look = "none"`); `vars.prompt` is a framework kind
number, not a judge name, so a custom HUD cannot bind to it.
* The ledger counted empty add-on hooks as Lua (Sintel, BD13); it now
reads each hook's body.
**Shared by several types (the most leverage):**
| Capability | Needed by | Forge today |
|---|---|---|
| Random choice among authored alternatives (clips, rows, orders, segments, deaths), drawn per game, level, or use, from the seeded stream | QTE (SAe, DLe, hayate, BD13, starblazers, SDQ, hq-timegal), all 24 gun games, the time-travel game | `chance`, `spawner pick=random` only |
| Progression as data: next level by rule over beaten, collected, or set; unlocks; "any of these counts"; a finale on a condition | QTE maps (4), QTE hooks (DL2e, BD13, SAe), all gun games, the time-travel game, metroidvania | rules and `goTo` only |
| A map screen: nodes, a cursor graph, stills by beaten set, completion markers, reveal clips, timeout | 4 QTE titles, 2 segmented, gun-game menus (panels by shot), the time-travel game, platformer world maps | missing (the QTE runs the game's `doLevelSelect`) |
| Deaths as data: the clip by cause, by level, from a range, with an aftermath clip (nag, undertaker, second clip) and conditions | QTE (SAe, BD13, hayate), all gun games, the time-travel game | a flat random pick |
| Per-level, per-move, and per-difficulty overrides of any constant, and settings that force others | QTE hooks (6 titles), gun games' difficulty tables, the segmented lineage's hardcoded scoring | missing |
| Counters, streaks, quotas, tallies, and money (spend, buy, cap) | gun games (streaks, money as life, store), the time-travel game (items, trader), 3D gun accuracy and combos, collect-a-thons | vars and `addVar` by hand |
| Checkpoints and respawn separate from `die` | gun games (sections reached, 22), platformers, 3D gun areas | QTE checkpoints style only |
| Settings and a service screen for any game | every family | QTE loop only (15.5) |
| Attract mode, credits and continue, boards with name entry (letter grid by shot, stick, keyboard), for any game | every family; gun games shoot the letters | QTE loop only; `credit`, best score, `submitScore` |
| Two players: per-player vars, device assignment, join mid-game, twin HUD and boards, tie-break | 7 gun games, 3D gun games, QTE two-player modes | `gun.player`, pointer per mouse |
| HUD widgets: digits, icon rows, bars, gauges (mash, hold, boss), blinking, ticker, prompts per judge, labels over 3D entities (`sceneProject`), binding to instance vars | every family | hud layer with game-var binding; the QTE prompt for presses only |
| Tweens and eased motion (`tweenValue`) | rail speed, pop-outs, platformer feel, UI | engine only |
| Rumble (`controllerDoRumble`), positional sound, sound stop, mute, channels | gun games, 3D gun, platformers | engine only |
**By type, beyond the shared list:**
*QTE (windows, held, segments, checkpoints, trophies).* The segmented
lineage's level functions are data except their random draws: seven titles
are pure tables (Conan, Daitarn3, DragonTrainer, PussInBoots, SamuraiJack,
jeeg, freedomfighter) and can be converted now; the rest need random
choice. Move rows under judge names; a sequence judge for the compound
kinds; prompts and gauges for every judge; the HUD for all styles; a
random hidden prompt; choice-menu text as data; collectibles set on a
named move and saved with the slot.
*2D gun games (25).* The converter must lift the data out of the original
scripts first: the hitboxes live in `hitbox-*.singe` and the moves are
built by the game's `setupLevel`, not in the descriptions. Then: several
boxes per target per frame with a head zone in 2D; hit classes (enemy,
civilian, bonus, capture) with a judging order; a shooting window with a
second-chance hold; reload by border, bottom edge, button, on empty, or
immediate, and reload disabled per window; ammo overfill; full-auto with a
fire delay and a random jam; delayed judging (a shot that lands later); a
quick draw with a holster rule; lives or money as life with a store;
shooting menus with done panels refused and a timeout; crosshair choice,
recoil sprite, reload animation; hints and clues without repeats.
Title-specific, as hooks or data: the puzzles (roulette, cans, crystal
sockets, the signpost maze), the case layer of one title, outlaw capture.
The typing edition needs a typed-word judge (per letter, in a window, with
its own score formula), which belongs with text input.
*ActionMax (5).* Covered by Forge's core words; fixes: the sensor per room
and the `every` phase (ticks from its own start, not wall-clock seconds).
*The time-travel game.* Deaths by cause, hint clips by move with
conditions, a rewind item spent during a death, a trader screen, a weighted
slot machine, a bonus shrinking per death, a tutorial level, a perfect-run
ending, and per-language frames. One title; after the shared list, little
is left that is its own.
*3D light-gun games (none in the library).* Shots stopped by solid bodies
(above); rail speed, easing, a stop timeout, a cover offset (duck) that the
rail does not overwrite, and a `setTrack` for forks; the finished gun model
(auto-fire, damage, rate, spread, weapon pickups); hull or mesh targets;
labels and bars over 3D entities; slow motion (a global time scale; missing
in the engine); recoil output (missing in the engine); gun calibration in
Forge.
*2D platformers (none in the library).* **A 2D world cannot scroll**:
looks draw at node coordinates in overlay space, with no camera or view
offset (checked). Needed: a 2D camera (follow, dead zone, look-ahead,
bounds, room lock, shake) with editor pan and zoom; tile collision from the
`grid` look, tile properties, a tile painter, autotiling, parallax;
controller options (acceleration and air control, coyote time, jump buffer,
variable jump, air jumps, dash, wall slide and jump, ladders, swimming,
slopes, knockback) on `playerSetVelocity` and `playerSetGravityScale`, which
Forge does not reach; an edge-turning ground enemy; invincibility frames;
sprite animation with one-shots and per-state rates. One-way platforms need
an engine feature (collision filters).
*3D platformers (none in the library).* The same controller options;
momentum and turn rate; `solid` with a mesh or hull shape; heightmap
terrain; camera collision, dead zone, auto-recentre, and stick orbit; a
blob shadow; animation layers and speed blends (engine only); root motion,
decals, and level streaming are missing in the engine too. The editor needs
snapping, drop-to-surface, and multi-select.
**Proposed order** (shared first, then by titles blocked, then new genres):
1. The wrong-today list above.
2. Random choice, deaths as data, and overrides: they unblock most QTE
hooks, most of the segmented lineage, and the gun games' draws.
3. Settings, attract, credits, boards and name entry, and checkpoints for
any game (lift them out of the QTE loop).
4. The gun-game converter (hitboxes and moves out of the scripts) and the
2D gun model; then the 3D gun items on the same model.
5. Progression as data and the map screen.
6. HUD widgets, prompts and gauges per judge, and 3D labels.
7. The 2D camera and scrolling world, tiles, and the controller options;
then the 3D platformer items.
8. Engine work: collision filters (one-way), a time scale, recoil output,
root motion, decals, streaming.
## 18. The plan: everything section 17 found (written 2026-09-28)
To be implemented later, in the order given. Each package names what it
adds to a description (in the syntax of `testScripts/author/*.forge`), where
it lands in Author.singe, what the converters do with it, and how it is
proved. Scene numbers from 101 are free.
**Rules for every package.**
* Generic first: a capability is designed for any game, then the QTE, gun,
and other loops use it. No framework's name, numbering, or layout in
Forge; a converted title's look is data in its own description.
* One source for each: a value lives in one place in a description; a
capability lives in one place in Author.singe, and the QTE loop's own
copy (its attract, boards, service, deaths) is replaced, not kept beside.
* Each capability is proved three ways (17): a scene that exercises it
(with a headless screenshot check where it draws), a converted title that
uses it keeping its State trace, and authoring it with no Lua. The
vocabulary and the Forge book document it in the same change.
* The ledger is regenerated after each package; its counts are the
progress report.
### 18.0 Before everything: open the loops, and the birds test
The owner's worry (2026-09-28): that Forge will do only what it is
programmed to do. "What if a user wants a QTE game where they collect
birds by visiting certain scenes? Will we need a birds property?" The
answer must be no, and today it is only half no: birds can be counted with
a var and a `frame` rule on `discBetween` frames, shown by `setText` or a
sprite's `copies`, but the QTE loop is a sealed box. It raises no events
of its own, publishes five vars (score, lives, credits, level, prompt), and
takes no orders from rules. The same is true of the other loops. And the
packages below, as first written, add a named field for each thing the
library happened to do, which is the worry exactly.
**Forge's generality is its rule system** (vars, events, conditions,
actions, expressions, looks bound to vars). So every loop, and the shell
of 18.5, takes full part in it:
1. **Events at every moment a game could care about:** `gameStart`,
`levelStart`, `sceneStart`, `moveOpen`, `moveRight`, `moveWrong`,
`moveMissed`, `death`, `continued`, `levelDone`, `gameOver`, and the
loop's own screens (`screenShown` with its name). Each carries level,
scene, move, judge, and input, and a rule may name any of them
(`on = "sceneStart", level = 3, scene = 2`).
2. **Its whole state readable** as vars in expressions: level, scene,
move, judge, input, lives, the life bar, deaths, difficulty, what is
beaten.
3. **Actions that steer it:** `goLevel`, `goScene`, `skipMove`,
`giveLife`, `takeLife`, `killPlayer` (with a cause), `setNextLevel`,
`endLevel`, `showScreen`.
4. **Looks bound to any expression** (18.7's `bind`), so a count shows as
digits or as a row of icons with no copying rule.
5. **No predefined vars.** Score, lives, credits, level, and the rest are
the game's own vars, declared like any other; a loop keeps no private
copy. Today the QTE loops hold `q.iScore`, `q.iLives`, and `q.iCredits`
and copy them over the game vars every frame (`Q.publishVars` and three
copies of it), so a rule that adds to `score` is undone a frame later.
Instead a loop reads and writes the vars its description names
(`qte = { vars = { score = "points", lives = "hearts" } }`, defaulting
to the plain names). What the outside world needs to know is a mapping,
not a name built in: the boards and `submitScore` rank by a named var
(`by = "birds"`, deaths, time), the best score kept at `gameOver` is per
board, the bezel layer maps its digits to vars (`show = { score = "p1.points", lives = "hearts", credits = "coins" }`),
and the coin switch adds to the var the shell names. `addScore` and
`credit` stay as shorthand for `addVar`, and `score` is no longer created
when the game does not declare it.
**Special fields are shorthand, not features.** Where 18.2 to 18.9 add a
table for something rules could say (collectibles set on a move, deaths by
cause, progression, per-level overrides), the table compiles to rules and
does nothing rules cannot; a game that outgrows the table writes the rules.
The converters may write either. A capability that rules cannot express is
a missing event, condition, action, or var, and is fixed there, once.
**The birds test.** Every package is also proved on a made-up game that no
library title is: QTE birds collected by visiting scenes and shown as
icons, lost on a death, a secret level at ten; a gun game where civilians
pay you; a platformer whose double jump is bought with birds. Written as
scenes without Lua; if one needs a new named property, the package is not
done.
This package comes first: the events, vars, and actions for the QTE loop
(the loop already knows each moment; it has only to say so), then the same
for each loop as it is touched. Proof: scene 113, the birds game above,
headless, its bird count and secret level traced.
### 18.1 Package 0: wrong today
0.1 **3D shots stop at walls.** `fire` skips a body only when it is the
firing player's own, a trigger, or its type says `seeThrough = true`
(glass, foliage). Anything else ends the cast as a miss. Zones: the
zone whose centre the ray passes closest to, relative to its reach,
wins, not the last listed. Scene 101: a target behind a wall, one
behind glass, a zombie with overlapping head and body zones.
0.2 **Saves carry game vars.** A slot saves the declared vars the game
marks `saved = true` (18.4 counters) with the progress; the QTE's
`loadSavePlus`/`writeSavePlus` hooks go. Until 18.4, the loop calls
`writeSavePlus` where it writes a slot. Proved on DL2e and BD13 with
PORT_TWICE (the second run loads what the first collected).
0.3 **ActionMax's sensor in play only.** `util/forgePortActionMax.py`
puts the sensor entity in the play rooms, not the title, menu, and
game-over rooms. Proved by the ActionMax sound trace.
0.4 **`every` on wall-clock phase.** `every` gains `from = "room"`: ticks
at whole multiples of its period since the room began, not since its
last tick. ActionMax's heartbeat uses it.
0.5 **The prompt var names the judge.** `vars.prompt` becomes the judge's
name (`"press"`, `"mash"`, ...) with `vars.promptInput` (`"up"`,
`"button1"`) and `vars.promptFill` (0 to 1, for gauges); the
framework kind number leaves the vars.
### 18.2 Package 1: random choice
A value form accepted by every field that takes a value, in a description
or in a rule's action:
{ choose = { a, b, c }, weights = { 2, 1, 1 }, per = "game", key = "ending", noRepeat = true }
* `per`: `"use"` (default, a fresh draw each read), `"room"`, `"level"`,
`"scene"`, `"game"`, or `"load"`. A draw is kept until its scope ends.
* `key`: draws with one key share one draw, which is how one draw fixes
several fields (a level's rows and its intro).
* `noRepeat`: a `"use"` draw does not repeat until every choice has come up.
* `weights`: a weighted outcome (a slot machine's results).
* Every draw comes from the game's one seeded stream, so `--deterministic`
replays it, and a save keeps the draws in force.
Lands in `authorValue(value, context)` (one resolver every reader calls),
the draws in `AUTHOR_DRAWS`, saved with the game. The `chance` condition
and `spawner pick = "random"` become users of the same stream.
Converters: the QTE converter writes `choose` where a hook drew (SAe's
game-over clip, DLe's three level orders, hayate's finish bonus, BD13's
scene 4 or 5) by running the hook over a seeded range, as `swaps()` does,
and recording every outcome it produces; a hook whose outcomes do not fit
a choice stays a hook. The segmented converter writes a level's variants
and its drawn rows (starblazers, SDQ's random correct input, hq-timegal's
shuffled blocks); the gun converter writes random clip rows and the
three-in-ten encounters.
Proof: scene 102 (each `per`, `key`, `noRepeat`, and `weights`, printed
over a thousand draws and replayed under `--deterministic`); SAe, DLe,
and starblazers keep their traces.
### 18.3 Package 2: deaths as data, and named clips for any game
A description's top-level `clips` names clips and stills for any game, not
only the QTE:
clips = { deathShot = { from = 1200, to = 1260 }, undertaker = { choose = { { from = 5000, to = 5100 }, { from = 5200, to = 5300 } } } }
with a `playClip` action (`clip = "deathShot"`, `then = "hold"` or
`"resume"`), a `clipDone` event, and `stills`. Deaths are rules over them:
deaths = {
{ when = { { "test", expr = "event.cause == 'civilian'" } }, clip = "friendlyNag" },
{ when = { { "test", expr = "level == 3" } }, clip = { choose = { "d31", "d32" } } },
{ clip = { choose = { "d1", "d2", "d3" } }, after = { clip = "undertaker", when = { { "chance", percent = 50 } } } } }
The first entry whose conditions hold is the death; `after` is its
aftermath clip, which the trigger may skip. `event.cause` is `"wrong"`,
`"timeout"`, `"civilian"`, a move's name, or what a rule passes to `die`.
The QTE loop's `Q.setupDeathClip`, `swapDeath`, and extra-death clip become
this; a range (`from`/`to` clip numbers) is written as a `choose` by the
converter.
Proof: scene 103; SAe, BD13, and hayate_1080 keep their traces with
their `swapDeath` hooks gone.
### 18.4 Package 3: settings, overrides, and counters
**Settings.** A description declares its options once:
settings = {
{ name = "difficulty", label = "Difficulty", values = { "Easy", "Normal", "Hard", "Extreme" }, default = 2, group = "Game" },
{ name = "reload", label = "Reload", values = { "Offscreen", "Border", "Button" }, default = 1, group = "Gun" },
forces = { { when = { { "test", expr = "settings.difficulty == 4" } }, set = { prompts = 1 } } } }
Kept in the engine's save store per game; read in expressions as
`settings.difficulty`; any field may be gated on one (`when` on a type,
a rule, a room entity). A generic **service screen**, drawn by a
Forge-generated RmlUi document, lists the groups and rows, restores the
defaults, and opens on the service switch for any game (the QTE's
`Q.doServiceScreen` becomes this, its framework's rows written by the
converter as data). The QTE converter maps the 69 `dip_` settings to named
settings once, in one table (15.4's), not per title.
**Overrides.** A number field may be a table resolved in context:
score = { base = 100, byLevel = { [3] = 250 }, byDifficulty = { 80, 100, 150, 200 }, bySetting = { reload = { 100, 90, 80 } } }
`authorValue` resolves it with the level, difficulty, and settings in
force. A move row may carry its own `score`, `sets = { gem3 = true }`
(collectibles set on a named move), and `clip` overrides. The converter
turns `startConf`, `swapLevel`'s constants, and `specialScore` into these
by running them over each level and difficulty, as for random choice.
**Counters.** A var may be declared with limits and behaviour:
vars = { money = { value = 300, min = 0, max = 9999, saved = true }, streak = { value = 0, resetOn = "miss" }, gems = { value = 0, max = 11, saved = true } }
with a `spend` action (`name`, `amount`, `else` actions when short), a
`varReached` event, and `saved = true` (0.2). A **tally** screen action
shows lines of label, expression, and running total, then adds the total
to a var (finish bonuses, accuracy, a bonus shrinking per death).
Proof: scenes 104 (settings, forces, and the service screen, driven
headless) and 105 (overrides and counters); DL2e, DLe, hayate_1080, and
Esh_Aurunmilla keep their traces with `startConf`, `swapLevel`, and
`specialScore` gone; TimeGal keeps its service-walk trace (16.3).
### 18.5 Package 4: the game shell for any game
What every arcade-style game has around its play, lifted out of the QTE
loop into one `shell` table any description may use:
shell = {
attract = { steps = { { clip = "title" }, { still = "rankings", seconds = 5, show = "boards" }, { room = "demo", seconds = 20 } }, skip = "any" },
credits = { coinsPerCredit = "settings.coins", freePlay = "settings.freePlay", cap = 9 },
lives = { perCredit = 3, max = 9, extraEvery = 50000 },
continue = { clip = "continue", seconds = 10, cost = 1 },
boards = { { name = "normal", by = "score", size = 10, seed = { { "AAA", 10000 } } } },
nameEntry = { style = "grid", letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ.-", still = "enterName", select = "shoot" },
pause = { switch = "pause" } }
* The shell runs attract, coin, start, play, continue, game over, name
entry, and rankings, and hands play to the rooms and rules (or a loop
behaviour). Events `gameStart`, `lifeLost`, `continued`, `gameOver`;
actions `loseLife`, `endGame`.
* **Name entry** styles: `grid` (a cursor over letters, stick or
pointer), `stick` (up and down through letters), `keyboard`, and
`select = "shoot"` (letters shot on a still or clip, the gun games' way,
with the letters' boxes as a track).
* **Checkpoints:** a `checkpoint` action records the place (room, rail
point, disc frame, entity positions the type marks) and `die` with
`respawn = true` returns there with a life lost; the QTE checkpoints
style and the gun games' sections become this.
* **Two players:** `players = 2` gives each player a var table
(`p1.score`, `p2.ammo`; `player.score` in a rule with `event.player`),
a device assignment screen (press to claim a mouse or gun), joining
mid-game (`join` rule, a credit), twin boards, and a `tie` event the game
answers (the gun games' draw-to-break).
* The QTE loop's `Q.doIntro`, `Q.doHighScore`, `Q.doContinue`,
`Q.doGameOver`, boards, and name entry become the shell, and the gun
games' vocabularies stop carrying their own.
Proof: scene 106 (a small non-QTE game through the whole shell, headless,
coins, a continue, name entry by each style, the boards persisting across
two runs); TimeGal's standard, attract, and persistence traces (16) keep
agreeing; one gun game's attract and board.
### 18.6 Package 5: progression and the map
**Progression.** A description's `levels` (any game's, not only the QTE's)
with an `order`:
order = {
start = { choose = { { 1, 2, 3 }, { 2, 1, 3 }, { 3, 2, 1 } }, per = "game" },
next = { { when = { { "test", expr = "beatenAll{1,2,3,4} and gems == 11" } }, level = "secretFinal" },
{ when = { { "test", expr = "beatenCount{1,2,3,4} >= 3" } }, level = "quartz" },
"sequence" },
skipBeaten = true,
counts = { { 6, 7 } },
unlocks = { tier2 = "beatenCount{1,2,3,4} >= 3", finale = "beaten(6) or beaten(7)" } }
Expression functions `beaten(n)`, `beatenAll{...}`, `beatenCount{...}`,
`unlocked("tier2")`, and `level`; `counts` makes alternatives count as one
(beating 6 or 7 clears the node); `unlocks` are derived flags,
recomputed after a load (Chantze's `TestState`). A `nextLevel` action and
a `levelDone` event serve non-QTE games.
**The map screen**, for any game:
map = {
nodes = { { level = 1, x = 120, y = 90, up = 2, right = 3, still = { base = 4100, beatenOffset = 1 } }, ... },
stills = { byBeaten = true },
select = "stick",
timeout = { seconds = 20, pick = "firstOpen" },
refuse = "beaten",
markers = { look = { kind = "sprite", file = "Overlay/done.png" } },
reveals = { { unlock = "tier2", clip = "revealTier2" } },
music = "map" }
`select` is `"stick"` (a cursor graph), `"pointer"`, or `"shoot"` (panels
shot on a still, the gun games' menus, with `refuse` and `timeout`).
Events `mapChosen`; the QTE's `levelMap` screen and `doLevelSelect`,
`moveCursor`, `LvlMapComplete`, `AllowLvlEnd`, `AllowLvlQuartz`,
`ShowComplete`, `mapConf`, `TestDips`, and `TestState` hooks become this,
written by the converter where they are tables in the hook (the stills by
beaten set and the neighbour lists are), and left as hooks where they are
not.
Proof: scene 107 (a map by stick, by pointer, and by shot, reveals and
markers, a load re-deriving unlocks); SuckerPunch, f13, Chantze, Triad,
Daitarn3, and SamuraiJack keep their traces with their map hooks gone; one
gun game's menu.
### 18.7 Package 6: HUD widgets
Looks for the `hud` and overlay layers, each bound to any expression
(`bind = "gun1.ammo"`, `"p2.score"`, `"self.health / self.max"`), so a
game var copy is never needed:
* `digits`: a number drawn from a sprite font or a TTF, with padding.
* `icons`: a row or column of a picture, one per unit, with `max`.
* `bar`: a filled bar, horizontal or vertical, pictures or colours.
* `gauge`: a picture sequence chosen by a fraction (mash and hold gauges,
the gun games' power meters).
* `prompt`: the current move's prompt, one picture or animation per judge
and input (`pictures = { ["press.up"] = "arrowUp.png", mash = { "mash.png", "action.png" } }`),
slides and blinks as options, and a hidden or "?" variant.
* `ticker`: scrolling text.
* `label3d`: any look pinned over a 3D entity (`sceneProject`), for
warning rings and boss bars.
* Every look gains `blink = { period, from = "appear" }` and `showWhen`.
The QTE's `qteHud` look becomes a widget set: the converter writes each
title's widgets, positions, pictures, and fonts into its description (the
title's own layout as data), so all five QTE styles draw a HUD, and
`Q.hudDraw` and its framework constants leave Author.singe. The gun
games' ammo rows, badges, crosshair choice, recoil sprite, and reload
animation are widgets and a `pointer` option (18.9).
Proof: scene 108 (every widget, screenshots headless); TimeGal's HUD
trace keeps agreeing with the widget set; a held and a segmented title
draw their prompts (screenshots checked by a person once, then kept as
references).
### 18.8 Package 7: the QTE model completed
* **Move rows by judge name:** `{ from = 215, to = 235, judge = "press", input = "right", death = 17 }`,
with `judge` from 15.4's list and a `sequence` judge
(`inputs = { "up", "left" }`) for the compound kinds. The converter
writes names; the loop's `QTE`/`HELD` kind numbers leave the runtime.
* **One loop, options not styles:** windows, held, segments, checkpoints,
and trophies become option sets (15.4); `Q.karis332`-style switches and
the framework version go (15.3).
* **The segmented lineage as data:** the converter runs each
`setupLevelNN(seg)` over every segment under stubs and writes the rows;
seven titles are pure tables and convert first, the rest after 18.2. The
loop stops loading the game's `setupLevel`, `setupDeathClip`,
`NextLevel`, and `doLevelSelect`. The segmented lineage's scoring
constants become data (18.4).
* **The time-travel game** on the same model plus: hints by move type with
conditions (a `hint` field on a row, a rule), a rewind item (an action
that reverses the disc to the move's start during a death, spending a
counter), the trader and the slot machine (a map node and a weighted
choice), and its tutorial level (a level with its own scoring override).
* The retired globals (17.1's 455): the converter drops what is the
framework's own state; each drop is checked by the title's trace.
Proof: every QTE title's trace, the whole library once at the end of the
package (the sweep, not per change); the ledger shows no `framework`
judges and no `lua` level functions.
### 18.9 Package 8: the gun game model (2D and 3D)
**Converter first.** `util/forgePortKaris.lua`'s gun-game branch lifts
the data out of the original scripts: `hitbox-*.singe` into box tracks
keyed by frame; the moves from the game's `setupLevel` under stubs into
windows; menus into maps (18.6); deaths into 18.3; settings into 18.4.
The ledger then itemizes each title.
**The gun** (behaviour fields):
{ kind = "gun", player = 1, ammo = 6, max = 6, overfill = true,
reload = "offscreen", margin = 10, auto = { rate = 0.12 }, jam = { chance = 0.0025, clear = "reload" },
damage = 1, spread = 0, holster = "bottom" }
`reload` is `"offscreen"`, `"border"` (within `margin`), `"bottom"`,
`"button"`, `"empty"`, or `"auto"`; the `canReload` var turns it off per
window; `auto` fires while held; `jam` needs a reload to clear;
`holster` and the `holstered` condition make the quick draw a rule.
Weapons are types carrying these fields, and an `equip` action swaps them.
**The target and the shot:**
* `class = "enemy"` (or `"civilian"`, `"bonus"`, `"capture"`, any name),
judged in the order `judge = { "enemy", "bonus", "civilian" }` on the
game; `hit` carries `class`.
* 2D boxes per target per frame with parts (`{ x, y, w, h, part = "head" }`
in a box track), so a head zone works over video as in 3D.
* A `window` on a box track: frames in which a shot counts, and
`hold = { byDifficulty = { 1.0, 0.7, 0.5, 0.3 } }` seconds of second
chance on the last frame; events `windowMissed`.
* A delayed shot: `judge = "later"` with a clip, the hit judged when the
clip ends (a cannon).
* The typing edition's judge: a `typing` behaviour (words from a
`choose`, per-letter matching in a window, wrong keys counted, a score
expression, words that must not be typed).
**3D on the same model:** 0.1's walls; `target shape = "hull"` or
`"mesh"`; the rail gains `speed` (a var), `ease`, a stop `timeout`, a
`cover = { switch = "pedal", offset = { 0, -0.8, 0 } }` added after the rail
position (reload in cover by rule), and a `setTrack` action for forks;
`label3d` warnings and boss bars (18.7); weapon pickups by `equip`; a
`rumble` action (`controllerDoRumble`); positional sound on an entity
(`soundSetNode`); slow motion and recoil wait for 18.12.
Money as life, the store, streaks, and hints are 18.4's counters and a map
or menu; the puzzles (roulette, cans, crystal sockets, the signpost maze)
and one title's case layer stay hooks until a second game needs them.
Proof: scenes 109 (2D gun: classes, parts, windows with holds, every
reload mode, auto and jam, a quick draw, two players with a tie) and 110
(3D gun: cover, forks, easing, hull targets, pickups, a boss with a bar);
every converted gun game's trace against its original (the ALG family's
compare driver); the typing edition's trace.
### 18.10 Package 9: 2D platformers
* **The 2D camera:** a `view` on the `world2d` or overlay layer:
`{ follow = "hero", deadZone = { w = 80, h = 60 }, lookAhead = 40, bounds = { 0, 0, 4096, 480 }, lock = "room", shake = true }`.
Every look in the layer draws at its position less the view's; a look
with `parallax = 0.5` scrolls at half speed; the HUD layer does not
scroll. `shake` works in 2D.
* **Tiles:** a `tiles` look (a sheet, a tile size, layers of rows) with a
`tileTypes` table (`solid`, `oneWay`, `hazard`, `ladder`, `water`, any
tag the rules read as `tileAt(x, y)`); solid tiles merged into as few
static boxes as fit; autotile rules (a tile chosen by its neighbours).
* **The controller** (`platformer` fields, most on `playerSetVelocity` and
`playerSetGravityScale`): `accel`, `decel`, `airControl`, `coyote`,
`buffer`, `jumpCut`, `fallGravity`, `airJumps`, `dash = { speed, time, cooldown }`,
`wallSlide`, `wallJump`, `climb` (in a ladder tile or area), `swim`,
`slope`, `step`; a `launch` action (bounce, knockback, springs);
abilities as vars (`canDash`) the fields read.
* **Enemies and hazards:** a `groundWalker` that turns at edges and walls;
`health` gains `iframes`; stomp as a rule on `collision` with
`event.fromAbove` and `launch`.
* **Sprites:** `frames` per state gains `fps` and `loop = false`, and
raises `animationDone`.
* Checkpoints are 18.5's.
* **Editor:** pan and zoom on the 2D canvas, level and camera bounds, a
tile painter (palette from the sheet, brush, fill, erase, layers, tile
types, autotile preview).
Proof: scene 111 (a scrolling level with every controller option, tiles,
parallax, and a ladder; inputs driven headless, positions traced) and a
small complete platformer sample in `testScripts/author/`, built without
Lua.
### 18.11 Package 10: 3D platformers
* The controller options of 18.10 on `character`, plus momentum and a turn
rate (no snap to facing), long jump, ground pound, and wall kick as
options.
* `solid shape = "mesh"` or `"hull"` from the look's model; a `terrain`
look from a heightmap (`meshHeightmap`, `terrainGetHeight`).
* The camera: collision (a ray from target to camera), dead zone,
auto-recentre, stick orbit with zoom steps, camera zones by trigger.
* A blob shadow (a disc placed under the entity by a downward ray) and the
shadow quality settings (`sceneSetShadow*`).
* Animation layers and speed blends (`animationPlayLayer`,
`animationSetLayerMask`, `animationSetLayerWeight`).
* `wander` and `canSee` for enemies (`navRandomPoint`, `navRaycast`).
* **Editor:** snapping, drop to surface, multi-select, duplicate, rotate
and scale handles.
Proof: scene 112 and a sample, as 18.10.
### 18.12 Package 11: engine work
Things neither Forge nor the engine can do yet:
* Collision filters or layers, and one-way bodies (one-way platforms,
shots that pass teammates).
* A global time scale for physics, animation, and timers (slow motion),
with the disc and sound left at speed.
* Gun recoil output (a solenoid or the gun's own recoil command: which
guns and protocols is to be surveyed first).
* Root motion from an animation clip.
* Decals (blob shadows, bullet holes).
* Loading a room or model without a stop (streaming).
* A gun calibration screen in the menu.
Each lands as an engine feature with its Reference entry and CHANGELOG
line first, then its Forge word.
### 18.13 Also
* **The ledger grows** as the packages land: Picture and Sound
capabilities (widgets, prompts, sounds per event), the gun games
itemized (18.9), ActionMax reported by its converter, and "runs without
its Lua" regenerated by `util/forgeStandalone.py` rather than read from
an old list.
* **The text adventure** waits for a console look and a typed answer to a
question (17.1); both can ride on 18.5's name entry by keyboard.
* **Retired as packages land:** the QTE loop's copies of the shell, the
deaths, the service screen, and the HUD; `qteHud`; the framework kind
numbers; the style tables; every hook the converter absorbs; the gun
vocabularies, title by title, as each plays from its description.
* **Order and dependencies:** 18.0 first, then 0. 1 (random) before 2 (deaths) and
before the converter work in 3, 7, and 8. 3 (settings and counters)
before 4 (the shell reads settings and lives). 4 before 8 (the gun games
use the shell). 5 after 1 and 3. 6 can go beside 3 to 5. 7 after 1 to
6. 8 after 4 to 6. 9 and 10 need only 3 to 6 and can go in parallel
with 7 and 8. 11 whenever a package reaches it.