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

480 lines
16 KiB
Text

== Lesson 12: Score, Lives, and Game Over
image::learn/12-score.png[The finished lesson, 480]
What you have is a toy: it starts in the middle of the action, it never ends,
and nothing you do in it counts. What separates a toy from a game is that the
game keeps score, takes something away when you fail, stops, and lets you try
again.
This lesson adds all of that, and then adds the one thing that makes a player
come back tomorrow: a high score that is still there when they do.
=== A Game Is Always in One of Three States
Watch any arcade machine for two minutes. It shows a title and waits for
somebody. Somebody presses start and it plays. The player runs out of lives and
it says so, then goes back to waiting. Three states, and the machine is always
in exactly one of them.
Your script needs a variable for which one it is in:
[source,lua]
----
local TITLE = "title"
local PLAYING = "playing"
local OVER = "over"
local state = TITLE
----
`state` holds a string, and the three constants hold the only three strings it
is allowed to hold. You could write `state = "playing"` everywhere instead and
the program would run exactly the same -- until the day you type
`state = "palying"`. That is a working line of Lua and the game silently stops
doing anything, because no test anywhere matches it. Typing `PLAYING` wrong
gets you `nil` instead, which goes wrong loudly and at once. Spend three lines,
buy yourself an error message.
Lesson seven had an `if` that decided what the game did. This is the same idea,
grown up and given a name: a *state machine*. It is worth the fancy phrase
because almost every game ever written has one somewhere.
=== One Place That Decides
The rule that makes states worth having is that there is exactly one place that
looks at `state` and decides what happens. Put it in `onOverlayUpdate`, and
give each state a function of its own:
[source,lua]
----
function onOverlayUpdate()
overlayClear()
if state == TITLE then
updateTitle()
elseif state == PLAYING then
updatePlaying()
else
updateOver()
end
return OVERLAY_UPDATED
end
----
Everything the game was doing every frame -- moving the ship, the rocks, and
the shots, checking hits, drawing all of it -- moves inside `updatePlaying`:
[source,lua]
----
local function updatePlaying()
moveShip()
moveRocks()
moveShots()
checkHits()
drawWorld()
drawScore()
end
----
`drawWorld` is the drawing you already had, lifted out of `onOverlayUpdate` and
given a name:
[source,lua]
----
local function drawWorld()
spriteDraw(shipSprite, shipX, shipY)
for _, rock in ipairs(rocks) do
spriteDraw(rockSprite, rock.x, rock.y)
end
for _, shot in ipairs(shots) do
spriteDraw(shotSprite, shot.x, shot.y)
end
end
----
The other two states are quieter, because nothing moves in them:
[source,lua]
----
local function updateTitle()
overlayPrint(25, 5, "R O C K S")
overlayPrint(24, 8, "BEST " .. best)
overlayPrint(21, 11, "PRESS FIRE TO PLAY")
end
local function updateOver()
drawWorld()
drawScore()
overlayPrint(25, 8, "GAME OVER")
overlayPrint(21, 11, "PRESS FIRE TO PLAY")
end
----
`updateOver` draws the world again so the wreck stays on screen under the
words, frozen, because nothing is moving it any more. You get that for free by
not calling the movement functions. Stopping a game is not a special feature;
it is leaving things out.
The input callback needs the same single decision. While you are playing, left,
right, and fire mean what they meant in lesson eleven. In the other two states
there is only one thing a button can mean:
[source,lua]
----
function onInputPressed(what)
if state == PLAYING then
if what == SWITCH_LEFT then
movingLeft = true
elseif what == SWITCH_RIGHT then
movingRight = true
elseif what == SWITCH_BUTTON1 then
fireShot()
end
elseif what == SWITCH_BUTTON1 or what == SWITCH_START1 then
startGame()
end
end
----
Two states are handled by one branch there, because "press anything to play" is
the same answer on the title screen and on the game over screen. When they stop
being the same -- a game over screen that waits three seconds first, say -- you
will split them, and the shape of the code will tell you where to cut.
=== Score and Lives
Two numbers and three constants:
[source,lua]
----
local ROCK_POINTS = 10
local START_LIVES = 3
local score = 0
local lives = START_LIVES
----
They change in `checkHits`, where the game already knows what touched what. A
destroyed rock is worth points, so add the middle line of these three to the
shot-against-rock loop you wrote in lesson eleven:
[source,lua]
----
soundPlay(boomSound)
score = score + ROCK_POINTS
break
----
And a rock that reaches the ship costs a life, and possibly the game:
[source,lua]
----
for r = #rocks, 1, -1 do
if shipHitsRock(rocks[r]) then
table.remove(rocks, r)
soundPlay(boomSound)
lives = lives - 1
if lives <= 0 then
endGame()
end
end
end
----
`lives <= 0` rather than `lives == 0` is a small piece of insurance. Today
nothing can take two lives at once, so they can only ever be equal. The day you
add a rock that costs two, `== 0` steps straight over zero and the player keeps
playing on minus one life, while `<= 0` keeps working. Ask for what you mean.
Show them both in a line across the top:
[source,lua]
----
local function drawScore()
overlayPrint(1, 1, "SCORE " .. score)
overlayPrint(25, 1, "BEST " .. best)
overlayPrint(48, 1, "LIVES " .. lives)
end
----
Those numbers are character cells, not pixels, as they were in lesson one. The
overlay is 360 by 240 unless you change it, and a cell of the console font is
six pixels wide, so a line holds sixty of them.
=== Starting Again
Going from the title screen to playing is not one line. Everything the last
game left behind has to be put back:
[source,lua]
----
local function startGame()
score = 0
lives = START_LIVES
rocks = {}
shots = {}
shipX = (overlayGetWidth() - SHIP_WIDTH) / 2
movingLeft = false
movingRight = false
state = PLAYING
end
----
`rocks = {}` throws the old list away and puts an empty one in its place. The
two `false` lines matter more than they look: if the player was holding right
when the last rock hit, `movingRight` is still `true`, and the new game starts
with the ship pinned to the wall until they press and release the key again.
Bugs in this function are the most common bugs in any game, and they all feel
the same to play: the second game is subtly wrong and the first one was fine.
When that happens, read `startGame` and ask what the last game changed that
this one did not put back.
Ending a game is shorter, and it is where the interesting part of this lesson
lives:
[source,lua]
----
local function endGame()
if score > best then
best = score
saveSet("highScore", best)
saveFlush()
end
state = OVER
end
----
=== A Number That Outlives the Program
Everything your game has held so far -- `score`, `lives`, the list of rocks --
lives in memory, and memory is gone the instant the program stops. Quit the
game and the best score anyone ever got goes with it.
Singe gives every game a save of its own: a set of names with values under
them, written to disk for you.
[source,lua]
----
saveSet("highScore", 4200)
----
`saveSet` takes a name -- the *key* -- and a value. The key is yours to choose
and never changes; the value is whatever you want to remember. Numbers,
strings, `true` and `false`, and whole tables of those all go in. Things that
only make sense while the program is running do not: a sprite handle is a
number that means nothing tomorrow, and a function cannot be written down at
all.
Reading it back is the other half:
[source,lua]
----
local best = saveGet("highScore", 0)
----
That line goes near the top of your script, beside the other variables, and it
runs once when the game starts. The second argument is the *default*: what to
answer when nothing has ever been saved under that key. More about that in a
moment, because it is the whole of this lesson's error.
You do not have to ask Singe to write the file. It writes once at the end of
any frame in which something changed, however many keys you set, and again when
the game shuts down. `saveFlush` writes it immediately instead, which is what
`endGame` uses: a new high score is worth a file write of its own, because an
arcade cabinet gets switched off at the wall and a frame is a long time.
The manual's Save section lists the rest of the family. `saveDelete` forgets one
key, `saveClear` forgets everything -- that is the "reset high scores" in a
service menu -- and `saveGetAll` hands you a copy of the lot, which is useful
when you want to print the whole save while you are hunting something.
=== Where the File Actually Goes
It does not go beside your script, and this is not an accident.
A finished game may live on a disc, on a network share, or inside a single
packed file (lesson twenty-nine), and none of those can be written to. A game
that saves next to its own script works perfectly on the machine you wrote it
on and fails on half the machines it is installed on. Singe 2 did it that way
and it was wrong.
So everything Singe writes for a game -- the save, screenshots, its log --
goes in one place, and `singeGetDataPath` will tell you where:
[source,lua]
----
debugPrint(singeGetDataPath())
----
Put that at the top of your script for a moment and run it. You get a path
ending in a separator, under a `data` folder, and inside it you will find
`save.json`. Open it in your text editor: it is plain text, and your high score
is in there under the name you gave it.
That is the answer whenever you need a file of your own as well. Build the
name from `singeGetDataPath()` and it lands somewhere writable; write to a name
of your own invention and sooner or later it does not.
=== The First Time, There Is Nothing
The very first time anybody runs your game, no high score has ever been saved.
`saveGet` has nothing to hand back, so it hands back the default you gave it:
[source,lua]
----
local best = saveGet("highScore", 0)
----
Nothing saved, so `best` is `0`, the title screen says `BEST 0`, and the first
game anybody plays sets a record. That is exactly what you want, and it costs
one extra argument.
Leave the `0` out and `saveGet` has no default to fall back on, so it answers
`nil` -- the value Lua uses for "there is nothing here", which you met in lesson
eight. `nil` is not zero. You cannot add to it, you cannot compare it with a
number, and you cannot glue it onto a string. It goes wrong on the first frame,
every time, on every machine where the game has not been played -- which
includes every machine your players are about to install it on, and not the one
you are testing on.
Give every `saveGet` a default. It is the cheapest habit in this book.
=== What Just Happened
[source,lua]
----
local TITLE = "title"
----
A constant: a variable you set once and never change, with a name in capitals
so that you can see at a glance that it is one. Lua does not enforce that --
nothing stops you assigning to `TITLE` -- so the capitals are a message to the
next person reading, who is you in a fortnight.
[source,lua]
----
if state == TITLE then
updateTitle()
elseif state == PLAYING then
updatePlaying()
else
updateOver()
end
----
The one decision. Every frame goes through here, and nothing else in the game
asks what state it is in except `onInputPressed`. When you add a fourth state
later -- a pause, or a bonus round -- this is where it is added, and you will
know it is the only place.
The `else` on the end catches "any other state". With three states that is the
same as `elseif state == OVER`, and either is fine. What matters is that there
is no way for a frame to fall through all the branches and draw nothing.
[source,lua]
----
score = score + ROCK_POINTS
----
Not `score = score + 10`. A number with a meaning gets a name, once, at the top
of the file. When you decide a rock is worth twenty-five, you change one line,
and you change it without reading the collision code.
[source,lua]
----
saveSet("highScore", best)
saveFlush()
----
`saveSet` puts the number in the save; `saveFlush` puts the save on the disk
now rather than at the end of the frame. Both are inside the `if`, so the file
is only written when the record is actually broken, which is a few times a
session rather than sixty times a second.
[source,lua]
----
local best = saveGet("highScore", 0)
----
Read once, at startup, into an ordinary variable. The rest of the game reads
`best` rather than calling `saveGet` again, because the save is a file and a
variable is a variable. Write to both when the record falls.
=== Try It
. *Make it harder as it goes.* Multiply the rock speed, or the chance of a new
rock, by something that grows with the score. Two lines will do it.
. *Show the lives as ships.* Draw `shipSprite` once per life in the top right
corner with `spriteDraw` instead of printing the number. A `for` loop from
lesson four, and the ship is 32 wide.
. *Save more than a number.* Add `saveSet("gamesPlayed", saveGet("gamesPlayed",
0) + 1)` to `startGame`, and print it on the title screen. Quit, run again,
and watch it remember.
. *Find the file and break it.* Print `singeGetDataPath()`, open `save.json` in
your editor, change the high score to something absurd, and run the game.
Then delete the file entirely and run it again. Both of those will happen to
your players.
. *Reset it from inside the game.* Make a key you would never press by accident
call `saveClear()`, and check the title screen afterwards. Careful: `best` is
still in memory, and `saveClear` does not touch it. Work out what else has to
happen.
=== Break It on Purpose
Take the default out, so the line reads:
[source,lua]
----
local best = saveGet("highScore")
----
If you have already set a high score, nothing happens at all, which is the
trap: the save has the key, so `saveGet` hands it back. Delete `save.json`
first, then run it. The title screen never appears, and you get this, with
`rocks.singe` standing in for whatever you called your script:
----
Error executing function 'onOverlayUpdate': rocks.singe:190: attempt to concatenate a nil value (upvalue 'best')
stack traceback:
----
Read it the way lesson eight taught you. The file and the line take you to
`overlayPrint(24, 8, "BEST " .. best)`, and the complaint says something that
was `nil` was glued onto a string. `..` joins two strings, and `nil` is not
one.
The line it names is not where the mistake is. Line 190 is where the game
finally tripped over it; the mistake was made at the top of the file, where
`best` was asked for and nothing was there. That gap between where a bad value
is made and where it is noticed is the hardest thing about `nil`, and the
reason a default costs one argument and saves an evening.
Put the `0` back.
=== What You Learned
* A game is in exactly one state at a time: attract, playing, or over.
* Keep the state in one variable, with named constants for its values, and let
one `if` decide what a frame does.
* Ending the game is a matter of not calling the things that move it.
* `startGame` has to put back everything the last game changed, held keys
included.
* `saveSet` stores a value under a key, and `saveGet` reads it back on a later
run.
* `saveFlush` writes the file now, for the things you would hate to lose.
* The save goes in the game's own data directory, which `singeGetDataPath`
names. Never write beside your script.
* `saveGet` with no default answers `nil` when nothing was saved, and `nil`
breaks the first frame on a machine that has never run your game.
* A number with a meaning deserves a name at the top of the file.
=== Next Time
Your game is a game now, and it is a loose script in a folder you happen to
know the name of. Lesson thirteen turns it into something the engine can find
by itself, with a title, artwork, and an entry in the menu, so that a player
who has never heard of you can start it without typing anything.