Skip to content

Writing a game

<script src="room.js"></script>
<script src="game.js"></script>
<script>
Game({
title: "Tag", aspect: 4 / 3,
multiplayer: { max: 8, teams: 0, player: { x: [0, 800], y: [0, 600] } }, // every field a player sets, with its range
setup(g) { /* each match, everyone */ },
update(g, dt) { const a = g.axis(); /* move yourself */ g.me.set({ x, y }); },
host(g, dt) { /* ONE player: rules, timers, monsters */ g.shared.set({ round: 2, hp: { [id]: 80 } }); /* g.gameOver() ends the match (call it every frame if you like: only the first call in a match counts) */ },
onAction(g, from, name, data) { /* host: a player asked; return false to refuse */ },
onFx(g, from, name, data) { /* everyone: sparks and sounds */ },
draw(g, ctx, w, h) { /* g.players, g.shared (read-only; g.shared is frozen) */ },
});
</script>
Who Writes How
Each player own input and position g.me.set({...}), any time; clamped to the declared ranges
Host (one browser) rules, scores, health, everything that is not a player g.shared.set({...}) in host(), onAction() or setup() (starting values; only the host’s count)
Anyone a request to the host g.act(name, data) resolves {ok}; never merged or dropped
Anyone a cosmetic effect (arrives with the next frame, up to 50 ms later) g.fx(name, data)
Server membership, host, order, match number, clock -
  • Writes merge deeply: {hp: {a: 80}} changes one entry, null removes a key, lists are replaced whole. Values that did not change are not sent (room.js and the hub both drop them), so writing a whole object every step is fine; a write that changes nothing sends nothing.
  • Things the host moves (monsters, minions, shots) ease smoothly on every other screen when their position fields are named x, y (also z, angle, rot, and a quaternion qx, qy, qz, qw). Players ease the same way. Others are drawn in the past by a delay measured per room (mean delay of the last 40 updates + 2 send intervals + its spread, 100 to 500 ms; usually 200 to 400), at their senders’ sample times; the drawing clock follows it by running 2% fast or 4% slow; a late update: the last speed for 100 ms, then hold. Measured through the real code (bench/interp-real.mjs): 0% jump frames, speed within 1%, for 4 and 16 players with 30 ms jitter.
  • Timers are end times on the shared clock: g.shared.set({endsAt: g.now + 60}). The clock: 4 pings 250 ms apart after connecting, then one every 5 s; the offset of the fastest of the last 8 (none older than 60 s) is used, reached over a second, never in one step.
  • g.shared is read-only all the way down (frozen): a write into it throws in strict code and does nothing otherwise, and list.sort() on a list in it throws, so copy first (g.shared.list.slice().sort()). The page never copies the whole state: each write makes new objects only along the paths it changes, so a frame costs the same at 3 KB or 60 KB (bench/clientcopy.mjs: 0.07 ms at 60 KB with 32 moving things, was 1.4 ms). structuredClone(g.shared) works (plain frozen objects).
  • A late joiner gets g.shared and g.players in full, so they must tell the whole story.
  • Host duty moves at once when the host leaves, hides its tab, or its game loop stops for 5 s; the new host continues from the server’s copy of g.shared. Keep nothing important only in host variables.
  • Teams: multiplayer.teams: 2 splits the lobby evenly (anyone can switch before the start); g.me.team, player.team, g.teams, g.teamColor(i).
  • No host app around the page (a downloaded copy): the game runs as a one-player game.

More of the shell, by kind of game:

Option or call For What it does
size: [800, 600] every game with fixed coordinates draw works in these units on any screen
world: [2400, 1600], g.camera.follow(x, y), overlay(g, ctx, w, h) races, dungeons, team battles a world bigger than the board, a camera, a layer on top in board units
onPointer(g, x, y), g.pointer board games, aiming a click or tap in board (world) units
buttons: [{id, label, key}] abilities, boost, fire up to 4 extra buttons: a key on a computer, a touch button on a phone
multiplayer.seats, seatNames, min chess and other games for a fixed few that many play (g.seats, g.me.seat), the rest of the room watches; watchers get no update, input or actions
multiplayer.cup: true tournaments a knockout bracket over the room: the shell seats each match and moves winners on; the game ends a match with g.gameOver(winnerId)
multiplayer.countdown: 3 races, shooters 3-2-1 on the shared clock before each match
g.shoot({x, y, vx, vy, life, r}), g.shots, g.hit(shot) shooters a shot is sent once and every screen moves it; the host decides what it hits
g.late races, anything with a start line true in setup() on a page that came into a running match (a late joiner, a reload): start next to the others, not on the start line
g.spawnNear({x: [0, W], y: [0, H], gap}) any game with a camera or a big world where to start or respawn so the others are on screen: a ring round the middle at the start of a match, later gap away from someone still playing (keys: ["x", "z"] in 3D, skip(player) to leave someone out)

The lobby also takes a friend’s room code typed in by hand (codes never use I, O, 0 or 1), the touch pad holds one direction per finger, and angles named angle or rot (radians) turn the short way round on other screens.

The lobby lists the other open rooms of the same game (asked when the lobby opens, then every 5 s while it shows and the page is visible): the host’s name, players / max, waiting or playing, how fast the host’s connection is (fast under 80 ms, ok under 200, slow above; the host’s browser runs the rules, so its link is what a newcomer feels) and busy when that room is sending less often than normal; Join moves you there. Full rooms are left out (the heading counts them). Listed: rooms in the same namespace, game version and save slot that are not private, not someone’s game alone and have someone in them; a page asking with its own options (room.js does) sees only rooms it could join. Rooms made by typing a new code are private. multiplayer.roomList: false hides the list; new RoomHub({ listRooms: false }) turns it off on the server (the answer is {off: true}, room.js gives null). A game can draw its own list: tickcli.rooms() resolves [{code, host, players, away, max, started, match, ping, slowed, full, you?}] (players: connected; away: dropped with the seat held; ping: the host’s round trip to the server in ms, 0 = not known yet), and joining one is tickcli.join({spec, code}). Lists are built at most every 250 ms per game and asked at most once a second per socket (burst 2); up to 50 rooms, the joinable ones first, then waiting, then the fullest, then the newest.

One card over the whole play area (so an upright phone has room), top to bottom: the game’s name and one line saying what happens next (“Everyone here? Press Start.”, “Waiting for Ana to start.”, “Waiting for 1 more player.”), the room code with Copy invite link, who is here (Change my name for guests), ONE main button for the host (Start game; shown greyed out while players are missing, so it is clear what is missing; Continue first when there is a saved game), then the less common choices as small links (Join with a code opens the code field, Make it private, Play alone, Invite anyone) and other open rooms. Buttons are 44 px high or more (WCAG 2.2 asks 24), focus is outlined, the status line is announced to screen readers, and the touch pad stays hidden behind the lobby until the start.

A room where no person did anything for 15 minutes (idleMs) is closed: moving, actions, effects, joining, starting, coming back to the page and Keep it count; the host’s automatic updates and clock pings do not, so a game left running with nobody at it still ends. A minute before (idleWarnMs) every page hears idle {closesAt}: the lobby shows “Nobody has played for a while, so this room closes in about a minute” with Keep it (a notice during a match), and anything a person does takes it back (idle {closesAt: 0}). Saves are written first, then every page gets close code 4010 and the lobby offers Start a new room. A room idle over 5 minutes (listIdleMs) is left out of room lists before that. Rooms nobody is connected to were already gone after their seats’ hold (30 s, 5 min for a page put away). idleMs: 0 turns it off. (PlayFab lobbies work alike: any member activity resets the lobby’s time to live, and lobbies that look abandoned leave search first.)

Every page gets the same updates unless it cannot keep up. Then it gets fewer, never broken ones:

  • The hub watches each socket’s send queue: over 16 KB (rateBytes) that reader goes to every second frame, a second later still behind to every fourth (at 50 ms frames: 10 then 5 a second). Changes and effects in between are merged into the next frame it gets, so nothing is lost, only the in-between steps. It goes back one step after its queue stayed near empty for 10 s (rateUpMs): down fast, up slowly, so a phone on a weak signal does not swing. A reader changes rate only at a frame that every rate gets, and joins, leaves, host changes and starts send every rate’s frame first, so events never overtake a change. Past 64 KB the old rule still holds (skip, one full copy later; 256 KB: dropped).
  • The page (room.js) watches what the hub cannot: how late frames arrive compared with the link’s usual delay (a far-away player has a steady delay, a link that cannot keep up a growing one: over 250 ms more for 2 s = ask for half, then a quarter; under 60 ms for 15 s = one step back), and how fast the game draws (under 20 frames a second for 3 s = ask for half). It asks with rate {div}; the hub uses the slower of its own reading and the ask and tells the page (rate {div, every}), which then keeps others drawn far enough in the past to stay smooth (2 of the slower gaps, up to 800 ms). On Chrome, Edge and Android, navigator.connection sets the starting point (data saver or 3G: half, 2G: a quarter).
  • Drawing (game.js): a device that cannot keep up draws at a lower resolution first (the screen’s pixel ratio capped at 2, 1.5, 1, then 0.75), then at 30 frames a second; the game itself keeps stepping at its normal rate. Under 45 frames a second or over 12 ms of drawing for 3 s = one step down; 55 frames and under 5 ms for 15 s = one step up. g.quality = {tier: "high" | "medium" | "low", fps, scale, halfRate, rate, rtt} lets a game drop its own extras (particles, shadows, trails) on “low”; quality: false in Game({...}) turns the drawing steps off.

Not done on purpose: moving the socket or the room logic into a Web Worker (measured: the page’s network work is about 0.07 ms a frame, less than one worker message would cost).