Skip to content

Browser libraries

Load them with plain script tags, in this order: room.js (to play together), game.js, then game3d.js for three.js games. Each file’s header note is the full reference; it is shown here as written.

tickcli game.js - a shell for one-file mini games, alone or together. MIT. Load it (after room.js for multiplayer), then call Game({...}) once.
It owns: fitting the board to the space that is left (never clipped), crisp canvas on high-DPI screens, the score / best / pause bar, start, pause and game-over
screens, keyboard (arrows, WASD, space), swipe and an on-screen d-pad on touch devices, no phone zoom (quick double or triple taps, tap-then-hold, pinch,
long-press menu), pause when the tab is hidden, and a fixed-step loop.
You write: setup, update and draw.
Game({
title: "Snake", help: "Arrow keys or swipe to steer.", // shown on the start screen
aspect: 1, // board width / height (1 = square)
dpad: "auto", // "auto" (touch screens only) | "always" | "never"
step: 1 / 60, // seconds per update call
scores: false, // true = at game over, hand the score to window.gameScores (a high-score board the host app provides, if any)
scoresBetter: "high", // "low" when a smaller score wins (a time)
setup(g) { g.score = 0; ... }, // start or restart: reset ALL state here (multiplayer: the host may also set starting g.shared values here)
update(g, dt) { ... g.gameOver() ... }, // fixed step; g.score += 1 changes the score
draw(g, ctx, w, h) { ... }, // w, h are CSS pixels; ctx is already scaled
onInput(g, action) { ... } // "up" "down" "left" "right" "action" (space, tap)
})
Action games: g.held.left/right/up/down are true while a direction is held (keys or the touch pad); g.axis() gives {x, y} to move by.
g.state is "ready" | "playing" | "paused" | "over". g.best is kept in memory only. g.start() g.pause() g.restart() g.gameOver().
3D (three.js or plain WebGL): context: "webgl". The shell then takes no 2D context: g.canvas is yours (new THREE.WebGLRenderer({canvas: g.canvas})),
draw(g, null, w, h) runs each frame with w, h in CSS pixels, and the drawing size is yours too: when g.w, g.h or g.dpr change (the quality ladder
lowers g.dpr on a slow device) call renderer.setPixelRatio(g.dpr) and renderer.setSize(g.w, g.h, false). client/three.js (TickcliThree) does that
and more. No overlay() here (draw a HUD in the scene or in HTML). g.pointer.nx, ny are -1..1 (y up), ready for a raycaster. Positions the host
shares keep 2 decimals (metres to the centimetre; multiplayer.decimals changes it). Rotations: a quaternion in qx, qy, qz, qw (players declare
each as [-1, 1]) is eased as one turn on other screens.
Your own engine (Phaser, PixiJS, Babylon.js, Kaplay, PlayCanvas, a loop of your own): headless: true. The shell then adds only the lobby, the
notices and the countdown over your page (clicks pass through everywhere else) and leaves the page, its canvas, loop and touches alone. It still
runs the room: setup(g) at each match start, host(g, dt) on the host, onAction, onFx, g.me.set, g.shared.set, g.players (eased), g.gameOver().
From your engine's own update, read g.state ("playing" while a match runs), g.players and g.shared, and call g.me.set({x, y}). No draw, no
board, no d-pad here (g.held and g.axis() still follow the keys). g.sync(make, place, remove) keeps one engine object per other player:
if (g.state === "playing") g.me.set({x: hero.x, y: hero.y});
g.sync(function (p) { return addSprite(p); }, function (s, st) { s.x = st.x; s.y = st.y; }, function (s) { s.destroy(); });
Playing together (also load room.js before this file): pass multiplayer: {max: 4, player: {x: [0, 800], y: [0, 600]}}.
The shell then shows the lobby (room code, invite link, players, Start for the host), reconnects, and shows notices. Your code:
update(g, dt) every player: move yourself, g.me.set({x, y}) (sent 12 times a second at most, clamped to the declared ranges)
host(g, dt) runs on ONE player only (the host): rounds, timers, health, score, who won: g.shared.set({...}); g.gameOver() ends the match
onAction(g, from, name, data) host only: a player called g.act(name, data); return false to refuse
onFx(g, from, name, data) everyone: a cosmetic effect someone sent with g.fx(name, data) (sounds, sparks), yours included (others' arrive
with the next frame). g.shared.set may get the whole object every step: only changed values are sent
draw(g, ctx, w, h) draw g.players ([{id, name, me, host, online, away, state}]) and g.shared; both are read-only (frozen: copy before changing, e.g. list.slice().sort())
g.me = {id, name, team, state, set}, g.isHost, g.now (shared clock, seconds), g.kick(id) (host), g.room = {code, match, players}.
Teams: multiplayer: {teams: 2, ...}: the lobby splits players evenly (anyone can switch before the start), g.me.team and each player's team
(0, 1, ...), g.teams = [[ids of team 0], ...], g.teamColor(i). Things the host moves in g.shared (monsters, minions, shots) are drawn smoothly
on every screen when their position fields are named x and y (also z, angle, rot). Writes merge: g.shared.set({hp: {[id]: 80}}) changes one entry.
setup(g) runs for everyone at the start of each match (also on a page that reloads into a running match: its g.shared writes are then ignored).
g.gameOver() counts once per match. A held action key is one press. With no app around the page (a downloaded copy) it runs as a one-player game.
Board units, big worlds, pointer, extra buttons (all optional, alone or together):
size: [800, 600] draw in these units whatever the screen size (draw gets w = 800, h = 600; aspect follows)
world: [2400, 1600] a world bigger than the board: call g.camera.follow(x, y) in update; draw in world units; overlay(g, ctx, w, h) draws on top
in board units (scores, lap counter, mini map)
g.pointer = {x, y, down} in board (world) units; onPointer(g, x, y) for a click or tap (chess squares, aiming)
buttons: [{id: "dash", label: "Dash", key: "Shift"}] up to 4 extra buttons: g.held.dash, onInput(g, "dash"); shown on touch screens
More for playing together:
multiplayer.seats: 2 only that many play; everyone else in the room (up to max) watches. g.seats = [id or null], g.me.seat (-1 = watching),
g.me.watching, player.seat. seatNames: ["White", "Black"]. Watchers get no update(), input or actions. min: 2 = players needed.
multiplayer.cup: true with seats: a knockout tournament. The shell draws the bracket, seats each match and moves winners on; the game only
plays one match and ends it with g.gameOver(winnerId) (no winner given: the best g.shared.score among the seated).
multiplayer.countdown: 3 3-2-1 before each match on the shared clock; update() and host() wait for it (g.countdown = seconds left)
g.shoot({x, y, vx, vy, life: 2, r: 4, kind: "laser"}) a shot every screen moves by itself (no shared state): draw g.shots
([{id, from, team, kind, r, x, y, vx, vy, age}]); in host() test them against targets and call g.hit(shot) to remove one.
g.late true in setup() on a page that came into a match already running (a late joiner, a reload): start next to
the others instead of on the start line.
g.spawnNear({x: [0, W], y: [0, H], gap: 120}) {x, y} to start or respawn where the others can be seen: at the start of a match a ring
round the middle (or at: [x, y]); later (a late joiner, a respawn) gap away from a player still in the game.
keys: ["x", "z"] for 3D, skip(player) leaves someone out (e.g. a dead player).
Angles named angle or rot are radians and turn the short way round on other screens.
One game, both ways: a game with multiplayer: {...} also plays alone. The lobby offers "Play alone" (a private room of one: starts at once, keeps the
player's saved profile and saved game; min must be 1, no seats short of it, no cup) and, in a room of one, "Play with others"; End in the bar (alone only) ends the game. "Invite anyone" (when the host app offers it) asks the app
to let people without an account in (its public link). With no host
app around the page it simply runs as the one-player game. Write the game for g.players (1 to max) and it works in both.
Saved games (together mode, with a host app that keeps saves; otherwise everything below is simply off and the game starts fresh each time):
g.profile = {...} the player's own saved data (stats, level, items, weapons, outfits, hp, mp, lives left): a read-only copy, loaded before
setup() runs. g.profile.set({level: 3, items: {sword: 1}}) merges (like shared writes; null removes a key), is kept a
second later and when a match ends. Plain JSON, 16 KB, nesting 4 deep. Read g.profile each time (set() makes a new copy).
g.saves is true when saving works. What a player shows others still goes through g.me.set.
multiplayer.save: true the host's g.shared (map level, monsters and their stats, progress) is kept every few seconds and when a match ends.
A new room then offers Continue and New game; Continue starts from the saved state and g.resumed is true: setup() must
not reset what is in g.shared (writes to g.shared inside setup() are ignored while g.resumed). g.clearSave() (host)
forgets the saved game, e.g. when the dungeon is cleared or everyone died. New game over a save asks first. One room
at a time plays a save: a second room of the same game plays unsaved and its lobby says so. A save that
could not be read is never written over: the room plays unsaved, the lobby says so, and Continue appears
once a retry reads it.
Limits of every write (g.shared.set, g.me.set, g.profile.set): text up to 200 characters, lists up to 64 items, 64 keys per object, 4 levels deep,
2 KB per player, 64 KB shared. A write that breaks one is refused whole: nothing changes, nobody gets it, the screen says so once and the console
says where ("room: bad_value at mobs.12.name"). Sizes count bytes (non-English letters 2 to 4 each).
tickcli room.js - play a one-file browser game together, live. MIT. Load it BEFORE game.js and pass
multiplayer: {max, player:{field:[low, high]}} to Game({...}); the game shell (game.js) does the lobby, start, reconnects and notices.
This file is the low-level part the shell uses (window.tickcli). Pages normally never call it directly.
How it works: the page may be sandboxed with no network, so it says hello to the window around it (or to itself, when it is the top page); a relay
there (relay.js) answers with a private channel and keeps the one WebSocket to the server. The server decides who each player is, keeps the shared state and the order of changes, and picks one player as host.
Each player writes only their own state; the host writes the shared state; anyone can ask the host to do something (an action).
tickcli three.js - three.js scenes in tickcli games. MIT. Load three.js first (it sets window.THREE; nothing of it is bundled here), then
room.js, game.js and this file, and call Game({context: "webgl", ...}). Everything here is optional glue; the game owns its scene.
var R3 = TickcliThree, renderer, scene, camera, others;
Game({
context: "webgl",
multiplayer: {max: 8, player: {x: [-20, 20], y: [0, 5], z: [-20, 20], qx: [-1, 1], qy: [-1, 1], qz: [-1, 1], qw: [-1, 1]}},
setup(g) {
renderer = renderer || R3.renderer(g); // a WebGLRenderer on the shell's canvas (once: setup runs every match)
scene = new THREE.Scene(); camera = new THREE.PerspectiveCamera(60, 1, 0.1, 200);
others = R3.pool(scene, function (p) { return new THREE.Mesh(box, mat); });
},
update(g, dt) { ... move my object ...; g.me.set(R3.pose(myObj)); }, // x, y, z and the quaternion, rounded (12 sends a second at most)
draw(g) {
R3.resize(renderer, camera, g); // drawing size, pixel ratio (the quality ladder) and camera aspect
others.sync(g.players.filter(function (p) { return !p.me; })); // one object per player, placed where they are (eased), gone when they leave
renderer.render(scene, camera);
}
});
R3.renderer(g, opts) new THREE.WebGLRenderer({canvas: g.canvas, antialias: true, alpha: true, ...opts})
R3.resize(renderer, camera, g) when g.w, g.h or g.dpr changed: setPixelRatio, setSize (the CSS size is left to the shell), camera aspect.
True when it changed something. Call it at the top of draw.
R3.place(obj, s) obj.position from s.x, y, z; obj.quaternion from s.qx, qy, qz, qw (or obj.rotation.y from s.angle). Fields missing
in s are left alone.
R3.pose(obj, decimals) {x, y, z, qx, qy, qz, qw} of obj (position to 2 decimals, quaternion to 3): pass it to g.me.set or into g.shared.
R3.pool(parent, make, drop) objects kept in step with a list: pool.sync(list, pick) where list is g.players (an array, by id) or an object
keyed by id (g.shared.mobs). New ids get make(item, id) added to parent, every object is placed from pick(item)
(default: item.state for players, else the item itself), ids gone from the list are removed (drop(obj, id) to
free geometry). pool.get(id), pool.map (a Map), pool.clear().
Others are already eased by room.js (positions and the quaternion as one turn, the short way round); place() only copies.