Online services¶
What a game played online needs besides the game itself: talking to web services (HTTP),
letting in only the players who logged in (connection approval) and keeping their progress
(Save on a dedicated server, or a database behind a web service).
No service is built into the engine and none is required: below are two ways to do it, a hosted
service (Supabase) and a backend of your own.
A game that makes web requests says "network": true in its game.json (a build of one that
says nothing guesses it from its scripts, and warns; "network": false keeps its Android app off
the network; see Building and sharing).
The player's password only ever goes from their device to the service, over HTTPS. The game connection carries the token the service gave, then gameplay.
HTTP¶
HTTP.get("https://api.example.com/news") { |response| @news = response.json if response.ok? }
HTTP.post("https://api.example.com/scores", json: { player: id, points: 120 },
headers: { "Authorization" => "Bearer #{token}" }) do |response|
next Debug.warn("scores: #{response.error || response.status}") unless response.ok?
show_ranking(response.json[:top])
end
HTTP.get,post,put,patch,delete(url, json:, body:, headers:, timeout:).json:sends a hash or an array as JSON (with itsContent-Type);body:sends a text as it is.- The block gets the
responsea moment later, on the game's own thread: the frame never waits.ok?(a 2xx status),status(0 when no answer came),headers(names in lower case),body,json(nil when the body is not JSON) anderror(why no answer came). response.jsongives hash keys as symbols when your scripts use that symbol, as strings otherwise (as messages do).- Addresses are
https://, checked against the certificates the system trusts, orhttp://to this machine or the local network only (a backend you run beside the game): 127.x, 10.x, 172.16.x to 172.31.x, 192.168.x and 169.254.x. No request goes to a cloud's metadata address (169.254.169.254, 100.100.100.200), overhttp://orhttps://. Overhttps://, an answer whose end only the connection's end marks must end with the server's TLS goodbye (close_notify), or it is taken as cut short. - An answer is at most 8 MB; 16 requests may be on their way at once; each gives up after 15
seconds unless
timeout:says otherwise (at most 120). Redirects are not followed. - When a level is loaded, the blocks of requests still on their way are not called (the requests still go out). When the game ends (or Play is stopped in the editor) they are given up.
Letting players in¶
The joining machine sends a hash with its join, usually the token it got when logging in. The game that hosts (on a dedicated server, the server's game) decides:
# on the device, after logging in
Network.join("play.example.com", auth: { token: session_token })
# on the server
Network.authenticate do |peer, auth, decide|
HTTP.get("https://auth.example.com/user",
headers: { "Authorization" => "Bearer #{auth[:token]}" }) do |response|
next decide.deny("not logged in") unless response.ok?
decide.accept(identity: response.json[:id], name: response.json[:name])
end
end
Network.on_join { |peer| greet(Network.identity(peer), Network.player_name(peer)) }
- The block gets the newcomer's id, what its join carried (a hash, empty when nothing) and the
decision to make:
decide.accept(identity:, name:)ordecide.deny(reason), now or later. - Until then the newcomer waits:
on_joinfires once it is accepted. One not decided on within 10 seconds is turned away. A player turned away getson_fail { |reason| }with the reason. Network.identity(peer)andNetwork.player_name(peer)say who a player is, on the machine that accepted it, until after itson_leave.- Without
authenticateeveryone is let in, as before.Network.authenticatewith no block turns it off. The handler stays when a level is loaded. - What a join carries is at most 4 KB once written as JSON. It is untrusted: check it with the service that issued it.
- It works with a player hosting too, and in a room on a relay, where the game of whoever opened the room decides: the relay decides nothing, it passes each join on and the decision back.
Saving on a server¶
Save on a dedicated server is written to the server's data folder (--data, see
Dedicated server), shared by all its rooms, and survives restarts.
Save keeps numbers, texts, true/false, Vector3, and arrays and hashes of those (hashes come
back with string keys), so each player's data can be kept under their identity:
Network.on_leave do |peer|
Save.update("players", {}) do |players|
players.merge(Network.identity(peer) => { "level" => @level, "coins" => @coins })
end
end
The server's rooms run at the same time, each on a thread of its own, and share its Save. Reading
a value and writing it back (players = Save["players"]; ...; Save["players"] = players) can lose
what another room wrote in between: change a value that more than one room may change with
Save.update, whose block gets the value as it is and returns the new one, with no other room
reading or writing the save meanwhile (keep the block short: the other rooms' saves wait for it).
The server keeps its saves in a database, one row per key, each written as soon as it changes:
a hash of every player is rewritten whole when one player changes, so with many players keep a
key per player instead (Save["player:#{Network.identity(peer)}"]). For queries such as a
ranking, send the data to a backend with HTTP.
Secrets of the server (the key a backend lets only the server write with) go in the server's
own save, written with the sqlite3 tool while the server is stopped (each value is stored as
JSON, so a text is in double quotes):
sqlite3 /srv/platformer-data/save.sqlite3 \
"CREATE TABLE IF NOT EXISTS saves (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL) WITHOUT ROWID;
INSERT OR REPLACE INTO saves VALUES ('backend_key', '\"some-long-secret\"');"
The server's game reads them with Save["backend_key"]; players' machines never see a server's
saves.
With a hosted service: Supabase¶
Supabase has a free tier with accounts and a database reached over HTTPS.
- Create a project. Its settings give the project's address (
https://<ref>.supabase.co), the public anon key (for the game) and the secret service_role key (for the server only). - In the SQL editor, create a table for each player's progress that anyone may read:
create table progress (player_id uuid primary key references auth.users, name text, coins int not null default 0);
alter table progress enable row level security;
create policy "anyone reads the ranking" on progress for select using (true);
On the device (SUPABASE is the project's address, ANON_KEY its anon key):
# Sign up once (Supabase may ask the player to confirm their email first)
HTTP.post("#{SUPABASE}/auth/v1/signup", json: { email: email, password: password },
headers: { "apikey" => ANON_KEY }) { |response| ... }
# Log in: the answer holds the session token
HTTP.post("#{SUPABASE}/auth/v1/token?grant_type=password", json: { email: email, password: password },
headers: { "apikey" => ANON_KEY }) do |response|
next show_error("could not log in (#{response.error || response.status})") unless response.ok?
Network.join(SERVER, auth: { token: response.json[:access_token] })
end
# The ranking
HTTP.get("#{SUPABASE}/rest/v1/progress?select=name,coins&order=coins.desc&limit=10",
headers: { "apikey" => ANON_KEY }) { |response| @ranking = response.json if response.ok? }
On the server, the token is checked with Supabase, and each player's coins (kept in the server's save, as above) are written with the service_role key, kept in the server's save too:
Network.authenticate do |_peer, auth, decide|
HTTP.get("#{SUPABASE}/auth/v1/user",
headers: { "apikey" => ANON_KEY, "Authorization" => "Bearer #{auth[:token]}" }) do |response|
next decide.deny("not logged in") unless response.ok?
decide.accept(identity: response.json[:id], name: response.json[:email])
end
end
Network.on_leave do |peer|
key = Save["supabase_service_key"]
coins = Save["players"][Network.identity(peer)]["coins"]
HTTP.post("#{SUPABASE}/rest/v1/progress",
json: { player_id: Network.identity(peer), name: Network.player_name(peer), coins: coins },
headers: { "apikey" => key, "Authorization" => "Bearer #{key}",
"Prefer" => "resolution=merge-duplicates" }) { |response| ... }
end
With a backend of your own¶
A backend of your own needs only a few calls. The essentials, in Ruby with Sinatra and SQLite (a
players table with id, name, salt, digest and coins, and a sessions table that ties
tokens to players; signing up inserts a player with a random salt and the digest of its password):
DB = SQLite3::Database.new("accounts.sqlite3", results_as_hash: true)
def digest(password, salt) = OpenSSL::KDF.pbkdf2_hmac(password, salt: salt, iterations: 100_000,
length: 32, hash: "sha256").unpack1("H*")
post "/login" do
name, password = JSON.parse(request.body.read).values_at("name", "password")
player = DB.get_first_row("SELECT * FROM players WHERE name = ?", [name.to_s])
correct = player && Rack::Utils.secure_compare(player["digest"], digest(password.to_s, player["salt"]))
halt 401, { error: "wrong name or password" }.to_json unless correct
token = SecureRandom.hex(32)
DB.execute("INSERT INTO sessions (token, player_id) VALUES (?, ?)", [token, player["id"]])
{ token: token, id: player["id"], name: player["name"] }.to_json
end
get "/me" do # who a token is: what the game server asks
token = request.env["HTTP_AUTHORIZATION"].to_s.delete_prefix("Bearer ").dup.force_encoding("UTF-8")
player = DB.get_first_row("SELECT players.* FROM sessions JOIN players ON players.id = player_id " \
"WHERE token = ?", [token])
halt 401, { error: "not logged in" }.to_json unless player
{ id: player["id"], name: player["name"] }.to_json
end
post "/progress" do # only the game server, which holds the key
halt 403 unless Rack::Utils.secure_compare(request.env["HTTP_X_SERVER_KEY"].to_s, ENV.fetch("SERVER_KEY"))
player, coins = JSON.parse(request.body.read).values_at("player", "coins")
DB.execute("UPDATE players SET coins = coins + ? WHERE id = ?", [coins.to_i, player.to_i])
halt 204
end
| Call | Sends | Answers |
|---|---|---|
POST /login |
{name, password} |
200 {token, id, name}, 401 {error} |
GET /me |
Authorization: Bearer <token> |
200 {id, name}, 401 |
POST /progress |
{player, coins} and X-Server-Key |
204, 403 without the key |
Sinatra listens on this machine only (-o 0.0.0.0 lets the local network in). On the internet,
put it behind HTTPS (a reverse proxy in front of it): games reach other machines over https://
only.
The platformer with it¶
The platformer demo logs in when its ACCOUNTS constant is set (scripts/main_scene.rb), next to
SERVER:
SERVER = "127.0.0.1".freeze
ACCOUNTS = "http://127.0.0.1:4567".freeze
The multiplayer menu then offers Log in (a name, then a password) before Play online,
which joins with the token. The server checks it with GET /me, lets the player in as
user-<id>, and when the player leaves keeps their coins in its save and, given the backend's
key, in the backend too:
mkdir -p /srv/platformer-data # once: the backend's key, in the server's own save (see above)
redgem-server games/platformer --data /srv/platformer-data
With ACCOUNTS empty (as it ships), players join as guests, each under an id their machine keeps,
and the server keeps their coins all the same. See scripts/accounts.rb and
scripts/server_gate.rb.