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.singeis 12,241 lines ofcivillian[2741] = {295, 111, 307, 135}. The largest artifact in a laserdisc game is not code. It is data entry against a video.ActionMax/38AmbushAlley.singeis 14 lines -- a gameID, five lengths, a background frame, two thresholds, four sensor coordinates, anddofile(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,playerSetSwimalready exist and are tested, so a "Platformer" behaviour is a checkbox over a character controller that works. So is "Follow Path" overnavAgentMoveTo, and "Physics Body" overbodyNew. - 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.singeis 40 KB of in-engine UI andMenuDocument.singea 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:15758calls_guiMouseMove()then_fireMouseMoved()unconditionally. Motion is never consumed, so the canvas always knows the pointer even while it is over the GUI.singe.c:15767offers 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. Layersworld2d,overlay,disc; looksbox,sprite,text; behaviourssolid,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 autil/script because the editor will be a Singe game and has to compile what it is editing without leaving the engine.testScripts/author/platformer.forgeandqte.forge-- the two unrelated genres, sharing nothing but the three nouns.testScripts/scene52.singeandscene53.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 aMISSwhen it is not.assets/AuthorEdit.singeandAuthorEdit.rml-- the editor, built to the spike: entity list and details in RmlUi, canvas in the overlay, picked withcollidePointRect.authorEditSave()writes the description back,authorEditBuild()compiles what is on screen.authorLoad/authorSavein 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:
Tabswaps the panel between the entities and the rules, the selected rule opens in place with its conditions and actions under it, andauthorEditRuleNew,authorEditRuleAdd,authorEditPartSetandauthorEditPartDeleteedit 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 fromAUTHOR, 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 UPandonGround hero, thenjump 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 writesforgeTemplate()and opens it. - Engine callbacks -- keys to
forgeKey, the left mouse button and motion toforgePress/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 inlast.txtand 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 astype.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. scene57runs Forge as itself -- noFORGE_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 isForge/Author.singe, and the compiler now loadsAUTHOR_RUNTIME);keyHeldcompared a key name against the scancode the engine delivers and never fired from a real keyboard;authorTouchingand thesolid/platformerbehaviours readlook.won sprite and text looks that have none (authorSizenow 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
luaaction 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:
- Positions are
x, y. There is noz, so there is no 3D at all. - 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. - Rules only poll. The engine delivers events --
onCollision,onTrigger,onNavArrived,onSoundCompleted,onInputPressed,onMouseMoved-- and the manifest cannot express any of them, sotouchingis a rectangle test every frame and a key press has no edge. - No state. An entity has
drive,text, andvisible. No health, no ammo, no per-instance variables, and no state machine -- and a state machine (idle, walk, attack, hit, dead) is an enemy. - No time structure. No timeline, no waves, no camera path, no timers
(
timerAfterexists and is unused), no levels, no game over. - No expressions and a flat AND.
onceis keyed by a tag the author picks and never resets, which breaks the moment something respawns. - 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.
- 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.
- Input is two conditions. No pointer position, no "pressed this frame", no second gun. A gun game is nothing without the pointer.
- 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 thesceneSet*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 withbezel = true.music-- a track and a volume;musicPlayon 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)
- Branching FMV (Dragon's Lair, Space Ace).
disc+overlayorhud. AframeReached/discBetweenwindow per decision,pressedfor the move,discBranchto 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. - Light gun over video (Mad Dog McCree, Crime Patrol, the ActionMax
titles).
disc+overlay.gun+hitboxestracks,hitevents,frameReachedfor reload prompts, scoring, civilians as boxes with a penalty. V + ED: the timeline with frame scrubbing and box drawing is the whole job;tweenValueinterpolates between keyframes. - QTE / rhythm over video (Road Blaster, Sega's Time Gal). Built. Adds
pressededges and asaytrack for prompts. - Video plus sprites (MACH3, Firefox, Cobra Command).
disc+overlay- 2D emitters. Sprite types spawned from a track by frame,
shooter,projectile,collision. V.
- 2D emitters. Sprite types spawned from a track by frame,
- Sensor games (ActionMax). A special case of 2: four hitboxes and a
light sensor;
gunwithpointerInand a frame window. V. - Interactive movie / adventure (Night Trap, Plumbers Don't Wear Ties).
disc+hud. Choices as GUI buttons bound todiscBranch, inventory as vars,saveSetfor progress. V (thehudlayer's bindings). - 3D over video -- House of the Dead's enemies over a filmed background.
discshown on avideolook 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
- Platformer (built). Gains
health,collectible,spawner,animatoron sprite frames, levels. - Shoot-em-up / twin-stick (Xenon, Robotron).
world2dwithout gravity oroverlayonly,keysgivingself.aim,shooter/projectiletypes,spawnerwaves from a track,collisionevents,health,emit. V. This is the second genre of milestone 1 because it forces types, spawn and events. - Run-and-gun (Contra): 8 + 9.
- Arcade physics (Breakout, Pong, Pinball).
world2dwithbody(bounce 1.0),solid,collisionevents,spawnerfor bricks from a grid. V. - Maze / dot-eater (Pac-Man).
world2don a grid:agentover a nav mesh built from the maze (navBuildon a plane with holes, or the 2D grid inbump.luawhich is already embedded),collectible,inStatefor frightened ghosts. V, plus agridlook that lays a tile map (E: small -- a tile look drawing a sprite sheet per cell). - Puzzle (Tetris, match-three, sliding).
overlay+ a grid of types,keys,timer, expressions over a 2D array var. V, but the array var and agridhelper are needed; the first puzzle will find the gaps. - Point-and-click adventure (Monkey Island).
overlaysprites, ahudverb bar,pointerIn,pressed,say, inventory vars,agentwalking on a 2D nav mesh,saveSet. V. - Visual novel / interactive fiction.
hudonly: a document with a text box and choices, vars for flags,say,playMusic,saveSet. V. - Tower defence (2D).
world2d,agentcreeps along a path (apathbehaviour suffices), turrets asshootertypes placed bypressedon a grid,health, waves from a track, ahud. V. - Top-down driving (Micro Machines).
world2d,body+thrust+ a turn rate,triggercheckpoints and a lap counter, anagentfor AI cars. V. (Jolt vehicles are 3D only: "a wheeled type in a 2D world" raises an error.) - Fighting / beat-em-up (Streets of Rage).
world2d,animatoron sprite frames,statemachines, and hit/hurt boxes keyed to animation frames -- the samehitboxestrack keyed by frame of a clip rather than of the disc. V: the track's key becomesframe,time, orclip. - Rhythm (Guitar Hero, Rhythm Heaven).
hud+musicor MIDI (midiPlay,onMidiMessage), a track of notes by time,pressedwithin a window,every(seconds). V; MIDI input as switches is E (small).
C. 3D
- Rail shooter (House of the Dead, Time Crisis, Virtua Cop). Worked through in the next subsection. The reference for milestone 2.
- First-person shooter / arena (Doom-like).
scene3d,character+camera first+ mouse look (keysfromonMouseMovedrelative),gunwith a raycast from the view centre,agentenemies over a nav mesh,health,spawner,collectible,hud. V. - Third-person action / adventure (Tomb Raider, Zelda).
character+camera follow/orbit,agentenemies,triggerdoors and switches,collectible,say,saveSet, levels. V. - 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.
- Racing / driving (Outrun, Mario Kart).
vehiclecars on a heightmap or model track,triggercheckpoints, lap and position vars,agentopponents (apathwith a vehicle following it),camera follow, ahudspeedometer fromvehicleGetSpeed. V. - Flying / space / boats (Star Fox, After Burner, Wave Race).
body+thrustwith pitch and roll fromkeys,camera follow,shooter,spawner; boats overbodySetWater. V. - Sports with a ball (bowling, golf, billiards, pinball in 3D).
bodywith bounce and friction,pushfrom a chargedpressed,triggerpockets and gutters,collisionevents,timerfor turns. V. - Physics puzzle / toy (Angry Birds in 3D, bridge builders).
body,joint(ajointbehaviour: hinge, ball, slider between two entities),softbodies (softNew),push,collisionscoring. V: the joint behaviour is the only new piece. - Tower defence / RTS-lite in 3D. 16 over a nav mesh with
agentcrowds andnavRandomPoint; selection bysceneUnproject+ raycast. V. - Walking sim / horror (Amnesia).
character+camera first,triggerscares,soundandemiton events,saynotes,lightlooks that flicker (addVaron intensity),saveSet. V. - Rhythm in 3D (Beat Saber-like without VR): 25's types coming down a
pathon a note track,gunorpointerInto hit them. V.
D. Not games, built the same way
- Attract modes, menus, kiosks:
hud+music+ tracks; the bundled menu is a hand-written one. - Cut-scenes: a
camera railtrack,saylines,playAnimationactions 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 railis apathon the view's node: position and look-at interpolated between points withtweenValue,nodeLookAteach frame, astopholding until the named wave is cleared (count == 0and the spawner has finished) or a timeout. Pure Lua. - The gun.
mouseGetPosition(device)inMOUSE_MANY(one device per player;--manymouseand the Sinden border already exist), thensceneUnproject(sx, sy, 0)andsceneUnproject(sx, sy, 100)give the ray,physicsRaycastgives the node hit. The runtime maps the node to the instance (AUTHOR_WORLDby node) and raiseshitwithpart= the node's name, so a ragdoll'sHeadbone or aweakspotchild is a part. A shot outside the overlay ismisswithoffscreen = true, whichreload = "offscreen"consumes. E, to verify: thatphysicsRaycaston 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.
seekmoves the node toward the camera each frame and raisesarrivedatstopAt;animatorswitches clips onstate(animationPlaywith a fade);healthtakesdamage, setshitthen the previous state afteranimationDone, and on zero setsdead, activates the ragdoll (ragdollNew/ragdollActivate) and destroys the instance onceragdollIsRestingor after a time.timeristimerAfterby name. - Civilians are the same type machinery with a
pathand a penalty rule. - Bosses are a type with more health,
weakspotchildren, ananimatorwith attack clips chosen by rules, and ahudbar bound toself.healthdrawn withsceneProjectabove the node. - Blood, flashes, shakes.
emitis a 3D emitter parented to the instance withemitterBurst;flashis a full-overlay box faded by a tween;shakeoffsets the view node for a moment. - Players, lives, credits.
liveswraps the score bezel (scoreBezelLives,scoreBezelCredits,SWITCH_COIN),players = 2gives two guns and the twin score.submitScorequeues to the master service throughMaster.singeand the game id in games.dat. - Over video (7): the same description with a
disclayer under the scene; the rail's stops becomeframeReachedstops 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
discSkipToFrameas 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
scene3dlayer 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 issceneUnproject+physicsRaycastagainst 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 (
Tabcycles entities, types, rules): types edited like entities, and the look'sfilefield offers the models, sprites, and sounds found under the game's directory withlfs.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:
physicsRaycastreporting the bone or child node it hit on a ragdoll or a compound (verify; add if not).- A
sceneSetBackgroundalpha 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 (
onMidiMessageexists; a mapping incontrols.cfgwould 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.
- 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.
scene3d,model/mesh/light/billboardlooks,animator,character,cameramodes,gunin 3D,seek,health,ragdoll, the rail. Genres: the rail shooter (20) and a 3D platformer (23). The editor gains the viewport.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.hudbindings,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.- 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.
- Sprite frames and
hitboxesby 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.singeandSinge/AuthorCompile.singeareforge/Author.singe,Forge.singeandForge/AuthorCompile.singe; nothing of Forge is inSinge/.- "The keyframe point, worth more than most of the rest" was not built. It is milestone 1 here.
- "The 2D looks draw with
overlayLineper row" stays true forbox; thespritelook draws its image in the editor now and needs frames. touchingassumedlook.w;authorSizeanswers for every look now, and the model above moves size into the look.- "A rule is a flat AND" is fixed by
anygroups above; "oncenever resets" byper = "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.
typesis the only place a look, behaviours, and vars are declared; anentitiesentry 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
zand the 2D editor never shows it. The runtime'sauthorSizeand thew, hscattered 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 saysreset = true. Vars declared on the game (score,flags, inventory) live across rooms. Ahud,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.
saywaits until the line is done,walkTountil the character arrives,waitfor its seconds,playAnimationoptionally until the clip ends,fadeuntil 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 asaddScore. 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 newon = "pressed"while a sequence is running is dropped unless the rule saysinterrupt = true. onceis 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.
hitboxesis 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 (collidePointPolygonexists), so a hotspot in an adventure is a hitbox track with one key.- Two players is the general case:
players = N, andkeys,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
spriteorvideobackdrop look drawn first, or ascene3droom with acameraper room --fixed(position, look-at, fov, matched to a painting),follow, ororbit.exitsaretriggertypes at the edges or hotspots with agoToaction 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 acamera followin 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 withmeshNew, and bakes a nav mesh withnavAddNodeandnavBuild, so a 2D room's walking is the sameagentbehaviour a 3D room uses over its floor. Click-to-walk isnavAgentMoveToat the click (2D: the overlay point; 3D:sceneUnproject+physicsRaycastonto the floor).walkToin a rule is the same call withwaits.onNavArrivedis thearrivedevent 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 theiry(spriteScaleexists), and draw order is byyso a character walks behind what is nearer the bottom of the picture.behindlooks -- 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
hotspottype: ahitboxesshape (rectangle, circle, polygon) with no picture, anamefor the sentence line ("look at window"), awalkTopoint, and vars.pointerInandpressedfind 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 thehuddocument shows them as buttons (LucasArts) or an icon bar (SCI1); the runtime builds the sentence from verb, hotspot, and, foruse, a second hotspot or an inventory item, and raiseson = "verb", verb = "use", item = "key", target = "door". A rule matches on any subset (verbalone 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 ahudoption. - The parser. A
parserlayer for the Sierra lineage:words = { look = { "look", "examine", "l" }, door = { "door", "gate" } }andon = "said", verb = "look", noun = "door"rules; unknown words answer from a table;keyboardSetModetext entry with a prompt line in thehud. Both input styles raise the sameverbevent, so a room's rules do not care which the game uses, and a game can offer both. - Inventory. An
inventoryvar on the player (a list of item types),give/takeactions, thehudbound to it as a scrolling list, items usable as theitemof averbevent, andhas(item)in expressions. - Dialogue. A
dialoguesasset per room or game: nodes ofsaylines (who, text, optional clip, and portrait) andchoices, each withwhenconditions,actactions,goto, andonce; atalkaction starts one and waits until it ends; thehudshows the choices; lines aresayactions with a duration from their length (or a voice file's) and asrt-style subtitle placement over the speaker viasceneProjectin 3D. A dialogue is a tree in the editor's panel, edited as an outline. - Flags and puzzles. Vars at game scope;
setVar/addVar;whenon them;once. A puzzle is state and rules, which the model already is. - Cut-scenes. A rule with
waitsactions andcontrols = falsefor its duration:walkTo,say,playAnimation,fade,cameraCut,wait,goTo. Tracks for anything timed to the frame. - Death and scoring (Sierra): a
dieaction with a message and restore/restart/quit choices from thehud;addScorewithmaxshown as "12 of 300";saveGame/loadGameactions capture the whole state -- room, positions, vars, inventory, dialogueonceflags -- throughsaveSetAll, and ahudrestore list. - 3D specifics. Characters are
modeltypes withanimator(idle, walk, talk, pick up) andagenton the room's nav mesh (baked from the room's floor solids withnavBuild);camera fixedper room with the painting on avideoorspritebackdrop look filling the view, orcamera followin a modelled room; hotspots aretriggerboxes or polygons on the floor;lookAtturns the head or the body toward a hotspot before asay; tank controls arekeysmapping forward/turn ontocharacter; point-and-click is the samenavAgentMoveTo. 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:
- 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. scene3d, models,animator,character,cameramodes, the 3D gun,seek,health,ragdoll, the rail. Genres: the rail shooter and the 3D platformer.- 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.
agentcrowds,vehicle,thrust,joint,body,push: racing and tower defence.- Branching FMV, lives, continues,
submitScore, results: the arcade plumbing every earlier genre then inherits. - Editor: types panel, asset browser, picker search, timeline polish, dialogue outline, polygon tools, play-from-here.
- 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.singerewritten:authorBegin(description)builds the layers, the vars, and the first room;authorRules(list)takes the compiled rules by event;authorFrameruns 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 hasvarswithstate.- Events:
frame,roomStart,roomEnd,pressed,released,collision(type pairs, an edge),hit,miss,death,spawn,timer,frameReached,gameOver. A rule names one withonand filters with the event's own parameters;eachscopes it to a type withselfbound. - Sequences: an action with
waits(say,wait) yields from the rule's coroutine throughauthorWait; a rule without one runs plainly.controls = falsetakes the keys away for its duration; the same rule on the same instance does not restart unlessinterrupt. - Expressions:
AuthorCompile.singecarries 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. Everynumberorexpressionparameter accepts one.anygroups nest. - The manifest's
emitfunctions 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 (
Tabcycles entities, types, rules, tracks), rooms (PgUp/PgDn,Nadds 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 withEpicking the event and its filters typed,Ofor ananycondition, 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.adocis generated from the manifest byutil/forgeVocabulary.lua(inbuild-docs.shand 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 -- soshooterandspawnersayspawns. - A gun hits only instances with a
targetorhitboxbehaviour. 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_NODEwhen it is made, so a ray or a contact maps to its instance with a table lookup. The first version walkednodeGetParent, 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. targetin 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_05on 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, astopheld untilpathNext,stoppedandrailEndevents.shakeoffsets the rail camera for a moment. charactermoves relative to the camera's ground axes (authorCameraAxes), which come from afirst/orbitcamera's yaw or afollowcamera's bearing to its target.- Ragdolls:
healthwithragdoll = truemakes the ragdoll at attach and activates it on death, keeps the instance forlingerseconds through a named timer, and takes it off the targets. - The editor's preview reuses the runtime's look
loadfunctions 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 withnavNew/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:navAgentNewread 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, asnavAddNodealready 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.
goTowithatnames an entity in the destination room to arrive at, which is how one rule serves both worlds.- The dialogue's
nextwas firstgoto, which Lua reserves. - Physics contacts now reach
collisionrules, so the rules'aandbtypes 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 firston the hero's eyes turned by the mouse,keysmoving relative to it, agunwithaim = "centre", zombies onwalker 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):solidwithmoving(a kinematic paddle that follows themover), abodyball with bounce 1,pushonroomStart. Found: events raised while the first room is built --roomStart, every placed entity'sspawn-- came before the compiled game handed over its rules, and were lost; they are kept and replayed byauthorRulesnow. - Maze (
maze.forge, scene 74): agridlook for the walls, corridors as walk areas, a ghost onwalker follow, dots eaten by collision. - Third person (
thirdperson.forge, scene 75):camera orbitunder the mouse,keysrelative to it, a sword swing ason = "pressed"witheach = "enemy"and a distance test. Found:eachon 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;distancetakes ids as well as instances and measures through the scene in 3D. - Space shooter (
space.forge, scene 76):thrustwith no gravity, ashooterfiring 3D projectiles, rocks as slow projectiles withhealth. - Rhythm (
rhythm.forge, scene 77): amusiclayer, notes as instances falling to a marker, a press scored by where the note is. - Bowling (
bowling.forge, scene 78): a heavybodyball shoved bypush, pins as bodies counted by how far they lean, atriggerat 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:
- What you will make -- one sentence and one picture of the finished game.
- What you need -- the tutorial before it (or nothing), and which files of the kit (13.3) it uses.
- 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. - 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.
- 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.
- The description -- the finished
.forgetext, 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 writes01-chooser.pngrather thansinge007.pngfor 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 byfaces.coin.png,crate.png,door.png,spike.png-- stills.tiles.png-- a 4-column tile sheet forgridlooks.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 inkit/LICENSE),crate.glba box with a texture.jump.wav,coin.wav,shot.wav,hit.wav,click.wav, and a shortloop.oggfor 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.mkvwill do until it is made.
Two consequences for the editor, both gaps today:
forgeExportcopies 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;forgeExportcopies named files; the chooser's Tutorials heading;== TutorialsinForge.adocwith: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(andX) copies every file a description names, at the same relative name; the compiled script setsAUTHOR_DIRand 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=2or1,2; 3,4. A scene3d layer typed in starts the viewport. 1to5for the panels;Deleteempties 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 (missbeforebranchMissed); the panel hides while a polygon is drawn; in a 3D roomAplaces on the floor under the viewport's middle.- The runtime: the platformer sets
facingandstate; a flat game with no disc paints its own dark ground;scaleByscales only what stands on the floor (anchor feet); text looks sort in front underdepthSort; the chooser lives in the library (forgeChooser*) and listsForge/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.
-
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
lightSensorbehaviour, apointerbehaviour (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. -
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.
-
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.
-
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;
frameFileVideopicks the one with the longest run (the game's) andFORGE.videoStartputs the cursor's disc frame on the right picture. Not done: the editor's canvas stays 720x480 whatever the game'ssize, 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
stepparameter moved which field the tutorial's Esc landed on, tutorial 3 stopped matching, and both ends were fixed:forgeEditCancelsets 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 underqteand names the script and Script/addons.singe. - The runtime is the framework's loop, with the framework's names.
The
qtebehaviour 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:
- 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.
- 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
hostedbehaviour 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 toAUTHORexactly as Author.singe does (abobbehaviour isAUTHOR.behaviours.bob = { params, attach, step }; a condition or action givesemit). 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 sharedauthorBeside(name)that the qte loop's script loading now uses too -- and records what it added;authorVocabularyForget()takes that out again.authorKeyValueandauthorSwitchValueare 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 emitsauthorVocabulary("...")afterAUTHOR_SOURCE_DIRin the built script, so the game loads it wherever it runs. - The editor loads it in
forgeBegin(forgetting the last game's), forgets inforgeClose, has avocabularyfield (kind file,.singelisted) on the game's form that reloads as it is set, andforgeFilesNamedtakes 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 apressedevent heard throughhearsAlland read inon, so the switch handler in the core no longer knows about sensors. The converter emits thevocabularyline; all five ActionMax compares still agree. - Scene 97 (
testScripts/author/vocab.forge+vocab.singe) covers it from the repo alone: the editor knowsbob,bobbing, andsetBobwhile 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:
- 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