532 lines
20 KiB
Text
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.
|