== 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.