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

532 lines
20 KiB
Text

== Lesson 28: Online
image::learn/28-online.png[The finished lesson, 480]
In lesson twelve you saved a high score. It is still there the next morning,
which is most of what a high score is for, but it is a high score on one
machine. This lesson puts a score on a board that other people can see.
It is also the lesson about failure. Everything else you have written fails
for reasons you can find: a file is missing, a number is wrong, a function is
misspelled. The network fails for reasons nobody in the room can see. A cable
was unplugged in a building you have never been to, and your game has to keep
playing. Most of what follows is about that.
=== What the Service Is
Singe can talk to a *master service*, a server that holds three things:
* an *account*, which belongs to the machine and not to any one game;
* a *catalogue* of games to download and update;
* *high score boards*, one or more per game.
The default service is `master.singeengine.com`, and an operator running their
own points the machine somewhere else. The manual's section on the online
service describes both sides.
Now the part people skip. The service is *not* multiplayer: there is no way for
two players to be in the same game. It is not a save-game cloud; lesson
twelve's save stays on the machine. It is not copy protection, and nothing in
it stops anybody playing your game. It is not required: a game that never signs
in runs exactly as it always did, which is the only sane default for something
that needs a working connection.
So the board is an extra. Keep the local high score from lesson twelve, keep
showing it, and let the online board be the thing that is sometimes there.
=== Signing In Happens Once, and Not in Your Game
The account belongs to the machine. It is created and signed in from the
bundled menu, behind the key mapped to `INPUT_SERVICE`, in the tool called
*Online Account*. The token it gets is written to `master.dat` in the data
root, one level above any game's own data directory, so every game on the
machine posts under the same sign-in.
Your game never asks for an email address and never asks for a password. There
is no function to do it with, and that is deliberate: a player typing a
password into a game they downloaded has no way to know where it goes.
What a player appears as on a board is a *handle*: three to twenty characters
of letters, digits, hyphens, and underscores, starting and ending with a letter
or a digit. No spaces, no dots, and no `@`, so a handle cannot even look like
an email address. It is kept apart from the account, it is the only thing about
a player that other players see, and it is chosen once -- an operator can
change one, a player cannot, because a board whose names keep moving is not
really anybody's history. Until a handle has been chosen, scores are refused,
since a score with nobody's name on it is not on a board in any useful sense.
The Online Account tool shows the name this machine posts under, and your game
reads it with `scorePlayerName()`, which answers `nil` when there is none.
Worth knowing before it puzzles you: a score submitted with no handle behind it
is refused by the *service*, not by `scoreSubmit`, so your game is told nothing
about it. If `scorePlayerName()` is `nil`, say so on screen while there is
still somebody there to read it.
=== Your Game Needs an Id
A board has to be kept under something. That something is `GAME_ID` in your
game's `games.dat` entry, the file you made in lesson thirteen:
[source,lua]
----
GAME_ID = "6f1e7b62-0a4e-4d9c-9b2f-1c7a5e3d8a10"
----
It is a UUID, in exactly that shape: eight hexadecimal digits, three groups of
four, then twelve, separated by hyphens. Generate one however you like and
paste it in; it does not matter which one you get, only that it never changes
again. The manual's entry for `singeGetGameId` explains why nothing else is
accepted.
`singeGetGameId()` hands it back, or `nil` when the entry has none. A game
without an id can be played but cannot be ranked, and asking first is how you
avoid offering a leaderboard that can never work:
[source,lua]
----
if singeGetGameId() then
-- showLeaderboardButton is your own function, not one of Singe's.
showLeaderboardButton()
end
----
=== The Six Calls
Everything a game does with the service is six functions, and they come from
`Singe/Framework.singe`, so the `dofile` at the top of your script is not
optional here:
[source,lua]
----
dofile("Singe/Framework.singe")
----
`scoreBegin()` says a play is starting. `scoreSubmit(value)` queues a score.
`scoreBoard(onDone)` fetches the board. `scorePlayerName()` is the handle.
`scoreWaiting()` is how many scores have not gone out yet. And
`scoreUpdate()` has to run once a frame, from `onOverlayUpdate`, or none of
the others ever finish.
That last one is the whole trick of this lesson, so it gets said twice.
Nothing here talks to the network when you call it. `scoreSubmit` writes to a
queue and returns immediately. `scoreBoard` remembers what you want and
returns immediately. The actual work happens a slice at a time inside
`scoreUpdate`, between your frames, which is why your game never stutters
while the wire is slow. Leave `scoreUpdate` out and nothing breaks, nothing
errors, and nothing ever happens.
[source,lua]
----
function onOverlayUpdate()
scoreUpdate()
overlayClear()
return OVERLAY_UPDATED
end
----
=== Posting a Score
At the end of a game:
[source,lua]
----
if scoreSubmit(score) then
fetchBoard()
else
saying = "Kept here only: " .. whyNotPosted()
end
----
`scoreSubmit` answers `false` when there is no account or the game has no
`GAME_ID`, and `true` when the score went into the queue. Notice what the
`else` does *not* do: it does not throw the score away, it does not apologise
at length, and it does not stop the player starting another game. It says one
short line, because the player did nothing wrong and cannot fix it from here.
The queue is the reason this is safe. A queued score is written to disk, so it
survives the machine being switched off, and it is handed to the service the
next time there is a connection -- an hour later, or next Tuesday. That is why
a cabinet in a room with bad wifi still ends up on the board.
Be honest with yourself about the edge of that promise: the queue holds scores
for a machine that *has* an account. If nobody ever signed in, `scoreSubmit`
answers `false` and the score is not kept for later, because there is nobody to
keep it for. `scoreWaiting()` tells you how many are still in the queue, and
showing that number somewhere is a kindness: it is the difference between "the
service is broken" and "three of your scores are waiting for the internet to
come back".
`scoreBegin()` is worth a paragraph of its own. Call it when a play starts, and
the service notes the moment by its own clock. The score that follows carries
how long the play took, measured on the server, which is the one number in a
submission that has not been through the player's machine. Nothing is rejected
on it: it is shown to whoever runs the service, beside the score, and a person
decides. A machine that was offline when the play started records no time,
which is honest rather than broken.
And while we are being honest: a submitted score is a *claim*. The game runs on
the player's computer, so nothing the client can do makes it more than that,
and a secret key shipped inside your game would only look like security. The
service records who claimed what, limits how fast scores can arrive, and flags
the wild ones for a person to look at. Design your board knowing that.
=== Reading the Board Back
`scoreBoard` is the first function in this book that takes a function as an
argument.
[source,lua]
----
scoreBoard(function(ok, result)
...
end)
----
You wrote functions in lesson five and gave them names. This one has no name:
it is written where it is used, handed straight to `scoreBoard`, and called
later -- possibly seconds later -- when the answer arrives. That is a
*callback* again, the same idea as `onOverlayUpdate`, except that this time you
choose who calls it.
`ok` is `true` or `false`. When it is `false`, `result` is a short string
saying why. When it is `true`, `result` is a table with two parts:
* `result.top`, a list of rows, each with a `name` and a `value`;
* `result.standing`, this player's own `value`, `rank`, and the number of
`players` on the board -- which may be missing, if this player has nothing on
it.
Between the call and the callback your game keeps running and drawing. That
gap is real and the player can see it, so put something in it. The script below
sets a line of text to `"Asking the service..."` before it calls, and the
callback replaces that line with either the board or the reason there is none.
A screen that says nothing for four seconds looks broken; a screen that says
what it is doing is just slow.
=== The Catalogue
The third thing the service does is list games. The menu's *Get Games* tool
shows everything the service offers, marks what is already installed on this
machine, and downloads, updates, or removes it. A download is checked against
the digest the catalogue published before it replaces anything, so a transfer
that arrives damaged fails instead of installing a game that will not run. A
game the service has withdrawn keeps working and is shown as installed but no
longer offered: all that stops is being given an update, because somebody who
has a game has it.
Your game does not call any of this, and should not. The catalogue matters to
you as the author of a game rather than as the writer of a script: it is where
your `.game` file goes when you publish it, which is lesson twenty-nine, and it
is another reason your `games.dat` needs a `GAME_ID` that never changes. If
you ever do want the list from inside a script, `Singe/Master.singe` is the
module underneath all of this and is worth reading once.
=== What the Service Knows About a Player
If you ship a game that posts scores, you are handing somebody else's data to a
server, and that is your responsibility even though you did not write the
server. So say it plainly, in your own game's about screen or read-me:
* The service holds an *email address* and a password for the account. The
address is how a lost password is recovered and nothing else. It is never
shown to other players and cannot be changed afterwards.
* The *handle* is what appears on boards. It is deliberately kept apart from
the account so that an address is never what other players see, and it is
checked against a list of names nobody should claim and words that do not
belong on a screen in a public room.
* A *score* carries its value, the board it is on, the time the service
recorded, and the handle. That is all.
* The catalogue knows *which games are installed* on the machine, and which
version, because that is how it offers you an update.
What none of it does is follow a player between games, sell anything, or reach
into the machine for anything you did not send. Your game should not add to the
list. If you attach extra information to a score -- `scoreSubmit` takes an
optional third argument for that -- keep it about the game.
=== The Script
A reaction test: wait for the word, press the button, score what is left of a
thousand. It is a small game, and the rest of it is the online part.
[source,lua]
----
dofile("Singe/Framework.singe")
local WAIT_MIN = 1000
local WAIT_MAX = 3000
local PERFECT = 1000
local state = "attract"
local goAt = 0
local score = 0
local best = saveGet("best", 0)
local saying = "Button 1 to play."
local top = nil
local standing = nil
local function whyNotPosted()
if not singeGetGameId() then
return "this game has no GAME_ID."
end
return "this machine is not signed in."
end
local function fetchBoard()
top = nil
standing = nil
saying = "Asking the service..."
scoreBoard(function(ok, result)
if not ok then
saying = "No board: " .. tostring(result)
return
end
top = result.top
standing = result.standing
if #top == 0 then
saying = "The board is empty. You could be first."
else
saying = ""
end
end)
end
local function finish(reaction)
score = PERFECT - reaction
if score < 0 then
score = 0
end
if score > best then
best = score
saveSet("best", best)
end
state = "result"
if scoreSubmit(score) then
fetchBoard()
else
saying = "Kept here only: " .. whyNotPosted()
end
end
local function play()
state = "waiting"
goAt = singeGetTicks() + math.random(WAIT_MIN, WAIT_MAX)
score = 0
top = nil
standing = nil
saying = ""
scoreBegin()
end
function onInputPressed(what)
if what ~= SWITCH_BUTTON1 then
return
end
if state == "waiting" then
state = "result"
score = 0
saying = "Too soon. Nothing posted."
elseif state == "go" then
finish(singeGetTicks() - goAt)
else
play()
end
end
function onOverlayUpdate()
scoreUpdate()
if state == "waiting" and singeGetTicks() >= goAt then
state = "go"
end
overlayClear()
overlayPrint(2, 0, "REACTION TEST")
if state == "attract" then
overlayPrint(2, 2, "Button 1 to play.")
elseif state == "waiting" then
overlayPrint(2, 2, "Wait for it...")
elseif state == "go" then
overlayPrint(2, 2, "NOW!")
else
overlayPrint(2, 2, "You scored " .. score .. ". Button 1 plays again.")
end
overlayPrint(2, 4, "Best on this machine: " .. best)
overlayPrint(2, 5, "Posting as: " .. (scorePlayerName() or "nobody yet"))
overlayPrint(2, 6, "Waiting to send: " .. scoreWaiting())
overlayPrint(2, 8, saying)
if top then
for place, row in ipairs(top) do
if place > 5 then
break
end
overlayPrint(2, 9 + place, place .. ". " .. row.name .. " " .. row.value)
end
end
if standing then
overlayPrint(2, 16, "You are " .. standing.rank .. " of " .. standing.players .. ".")
end
return OVERLAY_UPDATED
end
----
Run it. On a machine that has never been signed in, it plays, it keeps your
best score, and it says `Kept here only: this machine is not signed in.` That
is the important run, and it is the one most of your players will have.
=== What Just Happened
[source,lua]
----
local top = nil
local standing = nil
----
Two variables that are `nil` most of the time. `nil` means "nothing here",
and it is what a variable holds before anything is put in it. These hold the
board when there is one, and `nil` when there is not: while the request is in
flight, after a failure, and before the first game. The drawing code asks `if
top then` and draws nothing when there is nothing, which is the shape of
almost every piece of network code you will write.
[source,lua]
----
saying = "Asking the service..."
scoreBoard(function(ok, result)
----
The order matters. The line of text is set *before* the call, because the
call returns at once and the callback may not run for seconds. If you set the
text inside the callback only, the screen shows the last game's message until
the answer arrives, which is exactly the wrong message at exactly the wrong
moment.
[source,lua]
----
if not ok then
saying = "No board: " .. tostring(result)
return
end
----
The failure comes first, and it is short. `tostring` turns whatever came back
into text so that joining it with `..` cannot fail; `result` is a string when
`ok` is false, but a line of drawing code that trusts the type of something it
got from a server is a line waiting to crash. The player sees one sentence,
and the game carries on.
[source,lua]
----
if scoreSubmit(score) then
fetchBoard()
else
saying = "Kept here only: " .. whyNotPosted()
end
----
`scoreSubmit` answering `false` is not an error. It is a fact about this
machine, and the two facts it can mean are worth telling apart, which is what
`whyNotPosted` does: a missing `GAME_ID` is *your* mistake, and an unsigned-in
machine is nobody's. The local best score was already saved, two lines above,
before any of this was attempted. Save first, post second, always.
[source,lua]
----
overlayPrint(2, 6, "Waiting to send: " .. scoreWaiting())
----
The queue, on screen. On a machine with a connection this reads `0` forever
and nobody notices it. On a machine without one it climbs, and the player
knows the game is holding their scores rather than eating them.
[source,lua]
----
function onOverlayUpdate()
scoreUpdate()
----
First line of the frame, before anything else. Put it somewhere it cannot be
skipped by an `if`, because a `scoreUpdate` that only runs on the game-over
screen is a queue that only drains on the game-over screen.
=== Try It
. *Pull the plug.* Disconnect the machine from the network, play a few games,
and watch the `Waiting to send` number. Plug it back in and watch it go
down without you doing anything.
. *Delete the id.* Take `GAME_ID` out of your `games.dat` and run it again.
The message changes. Put it back.
. *Show more of the board.* The loop stops at five rows. The overlay is
eighteen character rows tall by default, so work out how many you can
actually fit, and move the `standing` line if you need to.
. *Name the board.* `scoreSubmit(score, "reaction")` and
`scoreBoard(onDone, "reaction")` use a board of that name instead of
`default`. Post to two boards from the same game -- say, one for the score
and one for a fastest single reaction -- and let button 2 switch which one
is shown.
. *Make the wait visible.* Between calling `scoreBoard` and the callback
running, draw a dot that moves. Then find out how long that actually lasts
on your connection, and whether it was worth drawing.
=== Break It on Purpose
Take the first line out of `onOverlayUpdate`, so the `scoreUpdate()` call is
gone, and play a game.
Nothing happens. No error, no message, no board, and the `Waiting to send`
number goes up and stays up forever. The game plays perfectly.
This is the worst kind of bug and it is worth meeting once on purpose: a
missing step that produces silence rather than a complaint. There is no line
number to look at, because nothing went wrong -- you asked for work and then
never gave it a chance to happen. When something in Singe that takes a
callback never calls you back, the first question is always whether the thing
that drives it is running every frame.
The other one you will meet is noisier. Put `scoreUpdate()` back, delete the
`dofile` line at the top, and run it:
----
Error executing function 'onOverlayUpdate': 28-online.singe:91: attempt to call a nil value (global 'scoreUpdate')
----
A few lines of traceback follow it, listing what called what. Read the first
line as always: the function the engine was calling, the file, the line, and
the complaint. `nil` again -- the name `scoreUpdate` has nothing in it,
because the file that defines it was never loaded. All six of these calls live
in `Singe/Framework.singe`.
=== What You Learned
* The service holds an account, a catalogue, and high score boards. It is not
multiplayer, not a save cloud, and never required.
* The account belongs to the machine and is signed in from the menu's service
tools. A game never asks for a password.
* A board is kept under `GAME_ID` in `games.dat`, a UUID that must never
change. `singeGetGameId()` reads it.
* `scoreSubmit` queues a score to disk and returns at once; the queue survives
the machine being switched off.
* `scoreUpdate()` must run every frame or nothing the service does ever
finishes.
* `scoreBoard` hands its answer to a function you write, which runs later.
Draw something in the meantime.
* Every network call can fail, and failing is not an error. Say one short
line and keep playing.
* A posted score is a claim, and the service treats it as one.
* Keep the local high score. It is the one that always works.
=== Next Time
Your game runs on your machine, out of a folder you have been editing for
twenty-eight lessons. Lesson twenty-nine turns that folder into one file a
stranger can copy onto their own machine and play.