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

330 KiB

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

  1. Platformer (built). Gains health, collectible, spawner, animator on sprite frames, levels.
  2. 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.
  3. Run-and-gun (Contra): 8 + 9.
  4. Arcade physics (Breakout, Pong, Pinball). world2d with body (bounce 1.0), solid, collision events, spawner for bricks from a grid. V.
  5. 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).
  6. 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.
  7. 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.
  8. Visual novel / interactive fiction. hud only: a document with a text box and choices, vars for flags, say, playMusic, saveSet. V.
  9. 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.
  10. 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.)
  11. 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.
  12. 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

  1. Rail shooter (House of the Dead, Time Crisis, Virtua Cop). Worked through in the next subsection. The reference for milestone 2.
  2. 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.
  3. Third-person action / adventure (Tomb Raider, Zelda). character + camera follow/orbit, agent enemies, trigger doors and switches, collectible, say, saveSet, levels. V.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. Tower defence / RTS-lite in 3D. 16 over a nav mesh with agent crowds and navRandomPoint; selection by sceneUnproject + raycast. V.
  10. 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.
  11. 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

  1. Attract modes, menus, kiosks: hud + music + tracks; the bundled menu is a hand-written one.
  2. 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_.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