singe/docs/lessons/16-branching.adoc
2026-09-22 21:57:42 -05:00

520 lines
18 KiB
Text

== Lesson 16: Branching
image::learn/16-branching.png[The finished lesson, 480]
A branching video game is a film with forks in it. A scene plays, the player
chooses, and a different scene plays next. Do that twenty or thirty times and
you have a game people will replay for years trying to find the ending they
have not seen.
In this lesson you will build one. It has four scenes, two choices, a timer
on each choice, a death, and three lives. The code that plays it is about
thirty lines, and it would still be about thirty lines if the game had two
hundred scenes -- because the game itself will not be written in code at all.
=== Imagine It Is a Dragon
The video everybody has is `Singe/menuBackground.mkv`, the same 420 frames
you have been driving since lesson fourteen. It is a ball of fire, a dragon
made of paper, the word `SINGE`, and then a magenta grid rolling away under
an orange sun forever.
It is not a drama. There is nobody in it to be in peril, and the sunset does
not care what you decide.
Play along anyway. The dragon is real enough for two minutes, and everything
you are about to write works exactly the same when the footage is a stunt
performer falling off a cliff. Here is the map, and the frame numbers are the
ones you found for yourself in lesson fourteen:
[cols="1,1,3",options="header"]
|===
| Scene | Frames | What happens
| `arrive` | 0 to 119 | Fire, and the dragon lands. *Left or Right?*
| `sunset` | 120 to 259 | It breathes fire. *Duck left, or run right?*
| `pit` | 260 to 339 | You are eaten. You lose a life.
| `escape` | 340 to 410 | The grid rolls away and you got out.
|===
Left is always right, so to speak: left from `arrive` goes to `sunset`, left
from `sunset` gets you to `escape`. Right goes to the `pit` both times, and
so does taking too long.
=== The Map Is Data, Not Code
Here is the whole game, and it is a table.
[source,lua]
----
local scenes = {
arrive = {
first = 0,
last = 119,
ask = 30,
prompt = "The dragon lands. Left or Right?",
left = "sunset",
right = "pit",
goesTo = "pit"
},
sunset = {
first = 120,
last = 259,
ask = 170,
prompt = "It breathes fire. Duck Left or run Right?",
left = "escape",
right = "pit",
goesTo = "pit"
},
pit = {
first = 260,
last = 339,
death = true,
goesTo = "arrive"
},
escape = {
first = 340,
last = 410
}
}
----
A table with names down the left instead of numbers, exactly as in lesson
six, and every value in it is another table. Each of those inner tables is a
*record*: one scene, described field by field.
`scenes.arrive` is the first scene. `scenes.arrive.first` is `0`.
`scenes.arrive.left` is the string `"sunset"`, which is the name of another
scene in the same table -- which is how the forks are written down. A scene
does not contain the scene after it. It contains the *name* of the scene
after it, and the name is looked up when the time comes.
The fields mean this:
`first` and `last`:: The frames the scene runs between.
`ask`:: The frame the question appears on. The player has from `ask` to
`last` to answer, which is the timer.
`prompt`:: What the question says.
`left` and `right`:: The scene each answer leads to.
`goesTo`:: Where the scene goes when nobody chose -- because the player ran
out of time, or because there was no question in the first place. For `pit`,
which is a death, it is where the retry goes back to.
`death`:: Present, and `true`, only on a scene that costs a life.
Three things are worth noticing about that table.
The first is that it is the design document. You can hand it to somebody who
has never written a line of Lua and they can read the game out of it. When
you want the dragon to fork three ways, you add a field. When you want
another scene, you add another record. You do not go looking for the code
that plays scenes, because you do not change it.
The second is that the records are not all the same shape. `pit` has no
`ask`, no `prompt`, and no `left` -- there is nothing to choose in it.
`escape` has no `goesTo`, because it is the end and goes nowhere. A field
that is not in a table reads as `nil`, which is the word you met in lesson
six for "nothing is there", and asking for `scenes.escape.goesTo` is not an
error. It is `nil`, and `nil` is an answer. Code that reads these records has
to be ready for that, and in a minute you will see the one line that is.
The third is what the table does *not* contain: nothing in it draws, plays,
or waits. It is a description. That separation is the entire trick of this
lesson.
=== One Function That Plays Any Scene
This is the code half, and there is only one function of any size. Written in
the threaded model from lesson fifteen, because a branching game is the most
sequential thing there is.
[source,lua]
----
local function playScene(name)
local scene = scenes[name]
discSkipToFrame(scene.first)
while discGetFrame() < scene.last do
local frame = discGetFrame()
local asking = scene.ask ~= nil and frame >= scene.ask
if not asking then
answer = nil
elseif answer == "left" or answer == "right" then
return scene[answer]
end
drawScene(scene, frame, asking)
singeYield()
end
discPause()
if scene.death then
lives = lives - 1
if lives == 0 then
ending = "The dragon wins. Game over."
return nil
end
waitForGo("Press the space bar to try again.")
end
return scene.goesTo
end
----
It takes the name of a scene, plays it, and returns the name of the scene
that comes next. That is its whole contract, and the game is then three
lines:
[source,lua]
----
function singeMain()
local scene = "arrive"
while scene ~= nil do
scene = playScene(scene)
end
waitForGo(ending .. " Space to quit.")
end
----
Start at `arrive`. Play whatever scene you are holding, and hold whatever it
hands back. When it hands back `nil` there is no next scene, the loop ends,
and the game is over.
=== The Whole Thing
Make `dragon.singe` in your `movie` folder and type this in. It is the
longest script in the book so far, and every piece of it has been explained
except the two small helpers, which come after.
[source,lua]
----
local FPS = 30
local scenes = {
arrive = {
first = 0,
last = 119,
ask = 30,
prompt = "The dragon lands. Left or Right?",
left = "sunset",
right = "pit",
goesTo = "pit"
},
sunset = {
first = 120,
last = 259,
ask = 170,
prompt = "It breathes fire. Duck Left or run Right?",
left = "escape",
right = "pit",
goesTo = "pit"
},
pit = {
first = 260,
last = 339,
death = true,
goesTo = "arrive"
},
escape = {
first = 340,
last = 410
}
}
local answer = nil
local ending = "You got out alive."
local lives = 3
local function drawScene(scene, frame, asking)
overlayClear()
overlayPrint(2, 2, "Lives: " .. lives)
if scene.death then
overlayPrint(2, 4, "That did not go well.")
elseif asking then
overlayPrint(2, 4, scene.prompt)
overlayPrint(2, 6, math.ceil((scene.last - frame) / FPS) .. " seconds left.")
end
end
local function waitForGo(text)
answer = nil
while answer ~= "go" do
overlayClear()
overlayPrint(2, 2, "Lives: " .. lives)
overlayPrint(2, 4, text)
singeYield()
end
end
local function playScene(name)
local scene = scenes[name]
discSkipToFrame(scene.first)
while discGetFrame() < scene.last do
local frame = discGetFrame()
local asking = scene.ask ~= nil and frame >= scene.ask
if not asking then
answer = nil
elseif answer == "left" or answer == "right" then
return scene[answer]
end
drawScene(scene, frame, asking)
singeYield()
end
discPause()
if scene.death then
lives = lives - 1
if lives == 0 then
ending = "The dragon wins. Game over."
return nil
end
waitForGo("Press the space bar to try again.")
end
return scene.goesTo
end
function onInputPressed(what)
if what == SWITCH_LEFT then
answer = "left"
elseif what == SWITCH_RIGHT then
answer = "right"
elseif what == SWITCH_BUTTON1 then
answer = "go"
end
end
function singeMain()
local scene = "arrive"
while scene ~= nil do
scene = playScene(scene)
end
waitForGo(ending .. " Space to quit.")
end
dofile("Singe/Framework.singe")
----
Run it:
----
Singe -R -v Singe/menuBackground.mkv dragon
----
Play it four or five times. Go left twice and get out. Go right and get
eaten. Sit on your hands and get eaten anyway. Lose all three lives.
=== What Just Happened
[source,lua]
----
discSkipToFrame(scene.first)
while discGetFrame() < scene.last do
----
A scene is a jump followed by a wait, which is the pattern from lesson
fifteen with the numbers coming out of the record instead of being typed in.
`discSkipToFrame` goes to a frame and plays from it, which is what a scene
change is.
The frame number is right the instant the call returns, so the `while` never
thinks it is still in the scene you just left.
[source,lua]
----
local asking = scene.ask ~= nil and frame >= scene.ask
----
The one line that is ready for a missing field, and the reason the death
scene can leave `ask` out.
`and` means both halves have to be true, as it did in lesson three, and Lua
checks the left half first. If `scene.ask` is `nil` the left half is false,
Lua stops there, and the comparison on the right never happens. Which is just
as well, because comparing `nil` to a number is an error and you will meet it
at the end of this lesson.
`asking` is the answer to "is the question on screen right now?", worked out
once a frame and used three times.
[source,lua]
----
if not asking then
answer = nil
elseif answer == "left" or answer == "right" then
return scene[answer]
end
----
While the question is not up, anything the player presses is thrown away. It
has to be, or a player leaning on the left arrow during the film answers a
question they have not read.
Once the question is up, an answer ends the scene at once. `return
scene[answer]` is worth a second look: `answer` holds the string `"left"` or
`"right"`, and `scene["left"]` is the same thing as `scene.left`. Putting the
name in brackets instead of after a dot lets you look up a field whose name
you are holding in a variable. Two lines of code for both answers, and for
five answers it would still be two lines.
The `elseif` also explains why only `"left"` and `"right"` are allowed
through. The space bar sets `answer` to `"go"`, and `scene["go"]` is `nil`,
so a stray space bar during a question would end the game in silence. Say
what you accept.
[source,lua]
----
discPause()
----
Right after the loop, and easy to forget. The scene has reached its last
frame but the disc has no idea the scene is over -- it plays straight on into
whatever footage happens to sit next in the file, which in a real game is the
middle of a scene from somewhere else entirely. Stop it the moment you stop
wanting it.
[source,lua]
----
if scene.death then
lives = lives - 1
----
`scene.death` is `nil` for every scene except `pit`, and `nil` counts as
false in an `if`, so this reads as "if this scene was a death". You do not
have to write `death = false` in the other three records, and you should not:
the absence of the field says it just as well, and there is then only one
place that decides which scenes are deadly.
The retry is the two lines under it. Take a life, and if there are any left,
hold on the frame and wait for the space bar. Then `return scene.goesTo`
sends the player back to `arrive`, and the record is what knows that. Change
`goesTo` to `"sunset"` and the retry becomes a checkpoint instead of a
restart -- one word, and the game's difficulty changes.
[source,lua]
----
overlayPrint(2, 6, math.ceil((scene.last - frame) / FPS) .. " seconds left.")
----
The timer. `scene.last - frame` is how many frames are left, and dividing by
the frames in a second turns that into seconds. `math.ceil` rounds a number
up to the next whole one, so the countdown reads `3`, `2`, `1` and never
`0.4` or `2.966666`.
`FPS` is `30` because this video runs at a little over thirty frames a
second. It is the one number in the script that is a guess, and it is a named
guess at the top of the file rather than a `30` buried in the middle of a
line, so that when you change videos there is exactly one thing to change.
Notice what the timer is measured against: the film. The player is racing the
video, not a clock, so the question can never outstay the shot it belongs to.
That is the reason branching games count in frames.
=== Why Not Write It Out by Hand
You could write this game as one long function. Play frames 0 to 119; if
left, play 120 to 259; if left again, play 340 to 410; else play 260 to 339,
take a life, go back to the top. For four scenes it would be shorter than
what you have.
For forty scenes it would be a disaster, and it is worth being precise about
why.
The frame numbers would be scattered through the code, each written once and
never named, so changing where a scene starts means finding every place it is
mentioned. The shape of the story would be buried in the shape of the `if`
statements, so nobody could see the game without reading all of it. And
adding a scene would mean editing the playing code, which is the code that
already works -- every new scene another chance to break an old one.
The version you wrote has one piece of code that plays scenes and one piece
of data that says what the scenes are. Adding a scene touches only the data.
That is worth the extra half hour it took, and it is the same idea underneath
the `games.dat` file from lesson thirteen: describe the thing, then write one
piece of code that reads the description.
=== Try It
. *Add an ending.* Give `escape` a question and a second ending scene that
reuses frames from somewhere in the film. Only the table changes.
. *Make it fair.* Lower `ask` in both scenes so the player has five seconds
instead of three, then raise it so they have one. Find the number you would
actually ship.
. *Move the checkpoint.* Change `pit`'s `goesTo` to `"sunset"` and play it.
Then think about what happens if a player dies in `arrive` -- and whether
the death scene should know where it came from rather than being told.
. *Count what the player has seen.* Add a table beside `scenes` that records
which scene names have been played, and show the count in the corner. Now
you have a game that can say "you have found three of the five endings".
. *Break the map.* Change `arrive`'s `left` to `"cave"`, a scene that does
not exist. Run it, go left, and read what Lua says. That error is the most
common one a data-driven game has, and knowing its shape saves an hour.
=== Break It on Purpose
Take the guard off the `asking` line, so it reads:
[source,lua]
----
local asking = frame >= scene.ask
----
Run it and go right. The `arrive` scene plays fine, because `arrive` has an
`ask`. Then the `pit` scene starts, which does not, and the game stops with
this:
----
Error executing function 'onOverlayUpdate': dragon.singe:68: attempt to compare nil with number
----
followed by a traceback -- a list of the functions that were running at the
moment it went wrong, innermost first.
Read it the way lesson one taught. The file and the line come first, and they
are exactly right: line 68 is the line you just changed. The complaint is the
rest: you asked Lua whether a number was greater than `nil`, and Lua has no
idea. `scene.ask` is `nil` because the `pit` record has no `ask` field, and a
field that is not there is `nil`.
One part of that message deserves an explanation, because it is a lie of
omission. The function named is `onOverlayUpdate`, which you did not write.
That is the framework's own, the one lesson fifteen said was installed to
drive your `singeMain`. The error came out through it, so that is the name
the engine had to hand. Ignore it. The file and the line are yours, and they
are the ones that are true.
=== What You Learned
* A branching game is a set of scenes, each of which names the scenes that
come after it.
* Keep the scenes as data -- a table of records -- and the code that plays
them stays one function however big the game gets.
* A record holds the name of the next scene, not the scene, and the name is
looked up when it is needed.
* A field that is not in a record is `nil`, which is not an error, and code
that reads records must expect it.
* `scene[answer]` looks up the field whose name is in a variable;
`scene.left` and `scene["left"]` are the same field.
* Put the timer on the video: a choice lasts from one frame to another, so it
can never outlast its own shot.
* Throw away anything the player pressed before the question appeared.
* Pause the disc the moment a scene ends, or it plays on into the next
scene's footage.
* A missing field standing for false -- `death` on three records out of four
-- keeps the description short and honest.
* Comparing `nil` with a number is an error, and `and` is how you avoid it.
=== Next Time
Your game asks left or right. The arcade machines this kind of game grew up
in asked something harder: point at the thing on screen and shoot it. Lesson
seventeen is about light guns -- where the player is aiming, what the game
does with that, and the calibration a real cabinet cannot do without.