singe/docs/Forge.adoc
2026-09-13 23:48:15 -05:00

253 lines
11 KiB
Text

= Forge
Scott Duensing <scott@kangaroopunch.com>
:revnumber: 3.00
:revdate: 2026
:doctype: book
:toc: left
:toclevels: 3
:sectnums:
:sectnumlevels: 3
:source-highlighter: rouge
:icons: font
:experimental:
[preface]
== About Forge
Forge is Singe's authoring tool: a way to make a game by describing it --
placing things, attaching behaviours, writing rules -- and having ordinary
Singe Lua written for you. It is itself a Singe game and is distributed on
its own, beside the engine rather than inside it. This document covers the
description format, the vocabulary, the compiler and the editor. The engine
calls the generated code makes (`playerNew`, `collidePointRect`,
`onKeyPressed`, `scriptPush` and the rest) are documented in the Singe Manual.
== Describing a Game Instead of Writing One
A game can be written as a *description* -- a table of layers, entities and
rules -- and compiled into an ordinary Singe game.
`Forge/AuthorCompile.singe` does the compiling and `Author.singe` is the runtime
the result calls. **No part of Forge ships with Singe** -- not the editor, not
the compiler, not the runtime -- so a game built with it carries its own copy of
that runtime and stands entirely on its own. Nothing is interpreted at run time: the rules
become real Lua `if` statements, so a description costs nothing per frame on a
Raspberry Pi, and the game it produces can be opened, read and edited by hand
like any other.
There is no notion of genre anywhere in it. A game declares which of the
engine's own layers it uses, and that is the only difference between a light
gun game, a platformer and a quick-time event over video.
=== The three nouns
*Layers* are what the game draws through: `world2d` (physics in the XY plane,
drawn into the overlay), `overlay` (flat drawing over everything) and `disc`
(the video the game is played over). A game lists the ones it wants.
*Entities* are things on a layer. Each has a position, a `look` (`box`,
`sprite` or `text`) and any number of behaviours. Every entity is a node,
whether or not the 3D scene is drawing, which is what lets a 2D game built this
way run on a machine with no GPU.
*Behaviours* are bundles over engine calls that already exist: `platformer` is
the character controller (`playerNew` and friends), `solid` is a
static body, `drift` moves at a constant velocity. Attaching one is a line in
the description rather than code.
*Rules* are conditions and actions. Every rule is tested every frame, in the
order written, and all of a rule's conditions must hold for its actions to run.
.A description, in full
[source,lua]
----
return {
title = "One rule",
layers = { { kind = "world2d", gravity = 1500 } },
entities = {
{ id = "ground", x = 360, y = 440,
look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 },
behaviours = { { kind = "solid" } } },
{ id = "hero", x = 120, y = 380,
look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170 },
behaviours = { { kind = "platformer", speed = 210, jump = 620 } } }
},
rules = {
{ note = "Run right",
when = { { "keyHeld", key = "RIGHT" } },
act = { { "run", entity = "hero", direction = 1 } } }
}
}
----
Compile it and run what comes out:
[source,lua]
----
dofile("Forge/AuthorCompile.singe")
dofile(authorBuild("mygame.game", singeGetDataPath() .. "mygame.singe"))
----
`testScripts/author/platformer.game` and `testScripts/author/qte.game` are
worked examples, and `testScripts/scene52.singe` and `scene53.singe` compile
and play them.
=== The vocabulary, and adding to it
Conditions and actions are not built into the compiler. Each is an entry in
the `AUTHOR` table declaring its parameters and the Lua it emits, so a new kind
of game is a set of entries rather than a new release. The conditions today
are `keyHeld`, `switchHeld`, `timeBetween`, `discBetween`, `onGround`,
`touching`, `below`, `flagSet` and `once`; the actions are `run`, `jump`,
`moveTo`, `setText`, `show`, `addScore`, `setFlag`, `discTo` and `lua`.
`once` deserves a word. Rules run every frame, so anything that should happen
a single time -- a door opening, a score awarded -- needs it:
[source,lua]
----
{ when = { { "touching", entity = "hero", other = "prize" },
{ "once", tag = "prize" } },
act = { { "addScore", amount = 100 },
{ "show", entity = "prize", visible = false } } }
----
=== The way out
The `lua` action takes a line of Lua and emits it as it stands. It is there on
purpose: when a rule needs something the vocabulary cannot say, that rule drops
to Lua and the rest of the game is unaffected. A description is a convenience,
not a cage, and the compiled output is a normal game you can stop describing
and start editing whenever it suits you.
=== The editor
`Forge/Forge.singe` edits a description, and it is itself a Singe game. It has
its own directory beside the games, appears in the menu like one, and packs to
`Forge.game` with `--pack`; the build does that itself (the `forge` target),
with this manual inside.
Nothing in it is a preview: the canvas is the same overlay at the same
coordinates the game will be played in, so what is placed is what is seen.
Started from the menu it opens on a chooser: the descriptions in its data
directory, any dropped into the `Forge` directory itself (those are opened as a
copy, since inside a `.game` they are read only), and *New game*, which writes
a starter -- ground, a hero that runs and jumps, a score readout -- and opens
it. The first run copies this manual, `Forge.pdf`, out of `Forge.game` into
that data directory, and the chooser says where it is. `Esc` in the editor closes the description (twice, when it has unsaved
changes) and `Esc` on the chooser leaves Forge. `P` plays: the description is
saved, compiled beside a copy of the runtime, and handed to the engine with
`scriptPush`; when the game ends Forge comes back on the same file.
A script can drive the editor instead, which is how the test scenes do it:
[source,lua]
----
FORGE_LIBRARY = true
dofile("Forge/Forge.singe")
forgeBegin("mygame.game")
function onOverlayUpdate()
local x, y = mouseGetPosition(0)
forgeDraw(x, y)
return OVERLAY_UPDATED
end
----
The entity list and the details are an RmlUi document; the canvas beside them is
drawn into the overlay and picked with `collidePointRect`.
The two compose because the engine offers a button to the GUI first and passes
on what it did not use, while pointer motion is never consumed at all -- so
point `forgePress`, `forgeDrag` and `forgeRelease` at the mouse
callbacks and clicks on the panels will not reach the canvas.
`forgeSave()` writes the description back, `forgeBuild(path)`
compiles what is on screen (and puts the runtime beside it, so the result
plays), and `forgeMove(index, x, y)` moves an entity without a pointer, which
is how `testScripts/scene54.singe` drives it.
=== Editing the entities
`A` adds a box in the middle of the canvas, `D` duplicates the selected entity
a little to one side, and `Delete` removes it. `Enter` walks the entity's
fields the same way it walks a rule's parameters: `id`, `x`, `y`, the look's
`kind`, then whatever that look takes, then `behaviours` -- typed as a list of
kinds, `platformer, solid` -- and then each behaviour's own parameters as
`platformer.speed` and so on. The fields come from the `AUTHOR` manifest, so a
new look or behaviour is editable the moment it is declared. Renaming an
entity renames it in every rule that talks about it. A `sprite` look is drawn
with its image once the file name is right, and as a box until then.
From a script, `forgeEntityAdd(x, y)`, `forgeEntityDuplicate()`,
`forgeEntityDelete()` and `forgeEntitySet(entity, field, value)` do the same.
=== Editing the rules
The panel shows either the entities or the event sheet; `Tab` swaps them. In
the rules, the selected rule opens in place and its conditions and actions are
listed under it, because a rule only means anything whole -- a `when` without a
`then` tells you nothing.
Point `onKeyPressed` at `forgeKey` and the whole editor
works without a pointer, which is how the bundled menu has always been driven
and what a cabinet wants:
[cols="1,4"]
|===
| `Tab` | entities or rules
| Up, Down | move through the list, and through the parts of the open rule
| Left, Right | slide the panel
| `Enter` | type a value for whatever is selected; again for its next value
| `Esc` | put the value back
| `A`, `D`, `Delete` | add, duplicate, delete the selected entity
| `N` | a new rule
| `C`, `T` | add a condition, an action -- picked from the vocabulary, with its help beside it
| `[`, `]` | move the rule up or down the sheet
| `Delete` | delete the selected rule, or the selected condition or action
| `U`, `R` | undo and redo, forty steps deep; a drag or a typed form is one step, and a change by hand ends the redo history
| `S`, `B`, `P` | save, build, play
|===
With the rule itself selected rather than one of its parts, `Enter` edits its
note.
A typed number comes back a number rather than a string, because `100` and
`"100"` compile to different source and a description that changed shape when a
value was retyped would stop round-tripping.
From a script, `forgeRuleNew(note)`, `forgeRuleAdd("when"|"act",
name)`, `forgePartSet(key, value)`, `forgePartDelete()`, `forgeRuleMove(by)`,
`forgeUndo()` and `forgeRedo()` do the same work. Adding a condition selects it, so its
parameters can be set at once.
The vocabulary comes from the `AUTHOR` manifest, so the rule editor never needs
changing when a condition or an action is added: it offers whatever is
declared. A new parameter gets a sensible starting value for its type, so a
rule compiles the moment it is made rather than only once every field is
filled in.
A description survives the round trip: loading one, saving it and loading it
again compiles to the same game, byte for byte. The editor depends on that and
the test asserts it.
=== Releasing a game
`forgeExport(folder, name)` writes everything a finished game needs into a
directory of its own: the compiled script, a `games.dat` so the menu lists it,
the description it was built from so it can be opened again, and **a copy of
the runtime**, taken out of Forge. `--pack` turns that directory into a `.game`
like any other, and it runs on a machine that has never had Forge on it.
Every built game finds its own directory to load that runtime from, using
`debug.getinfo` rather than `DIR`: `DIR` is the directory of the script the
engine was *launched* with, so a game reached by `dofile` -- a test, a
launcher, a preview -- would otherwise look beside the caller.
The panel slides. Drag the tab on its outer edge and it moves across the
window, so an entity that lives underneath it -- a score readout at `12, 12`
does -- is never permanently out of reach. `forgePanelTo(x)` moves it from
a script, and `forgeOverPanel(x)` says whether a point is currently
covered.
[#migrating]