Uniplay

Playing inside the Hub#

The Hub shell#

The Hub page stays loaded while the player plays: it runs the game in a full-screen frame from /<path>/game.html, and shows /<path>/ in the address bar. Going back to the Hub, or to another game, never reloads the page, so the room and its chat (Buds rooms) stay open. Opening /<path>/ directly (a reload, a shared link) starts the Hub straight into that game. Only one tab of the browser runs the Hub at a time: it holds the Web Lock uniplay:library, and another tab shows "Uniplay is open in another tab" with Use here, which steals the lock; the tab that loses it closes its game (running onExit), leaves Buds and waits. Query strings and # fragments on /<path>/ are passed on to game.html.

The frame is same-origin and allows autoplay, fullscreen, gamepad and microphone. A game finds the Hub on window.parent.uniplayShell (v: 1); the SDK's shell module does this, and does nothing when there's no Hub (a dev server, or game.html opened on its own).

Game side (uniplay.shell) Meaning
exit() Back to the Hub. Outside it, game.html goes to /; anywhere else nothing happens.
canExit Whether exit() does anything: show an "Exit" button only then.
onExit(cb) Before the Hub closes the game. Save here; a returned promise is awaited, about a second at most.
paused, onPause(cb), onResume(cb) The Buds overlay is over the game. Ignore input while paused.
notify(notice) A notice in the Hub's alert corner; false outside the Hub.

The Buds overlay (room, people, chat) opens with the key left of 1 (Backquote), or on a controller with Home, or Back and Start together. Games must not use these. The Hub also accepts Back alone while no game is running.

uniplay.shell.notify({ kicker, title, text?, icon? }) shows a notice in the Hub's alert corner (top right), where Buds messages appear; it returns false outside the Hub. The SDK's achievement popup goes there when the game runs in the Hub.

if (uniplay.shell.canExit) menu.add('Exit', () => uniplay.shell.exit());
uniplay.shell.onExit(() => uniplay.saves.write('main', state));
uniplay.shell.onPause(() => (inputEnabled = false));
uniplay.shell.onResume(() => (inputEnabled = true));

Buds rooms#

Every player has a room: its code is room in their profile (uniplay:profile), 6 characters from ABCDEFGHJKLMNPQRSTUVWXYZ23456789. Signed-in players keep codes out of sight: buds add each other by nickname or email, and accepted buds get each other's code. The Hub opens the room when it starts, so friends can join with the code at any time, and moves to a friend's room when the player joins one. A page is in at most one room.

Rooms are Trystero (WebRTC) rooms with app id uniplay-v1, room id the code in capitals and password the same code, over Trystero's Nostr strategy: relays only see encrypted connection offers; everything else goes directly between browsers. Pages exchange offers on the SDK's relay list (RELAYS in the net module: top.testrelay.top, relay.sigit.io, social.amanah.eblessing.co, nostr.islandarea.net, nos.lol, purplerelay.com), and two pages only meet if they share a relay, so a page that passes its own relayUrls should keep some of these. Inside the Hub there is one connection for the whole site, owned by the Hub page; games use it through uniplay.net. A game outside the Hub opens its own when it's given Trystero's joinRoom.

Every message is JSON on a named channel (a Trystero action, at most 32 bytes):

Channel Payload Meaning
u:sys { "t": "hello", "v": 1, "profile", "name", "avatar", "platinum", "room", "game", "hosting", "voice", "muted", "sdk" } Sent to each peer on connecting, and to everyone when the page opens or closes a game (game is a game id or null), starts or stops hosting it, joins, leaves or mutes in the voice call, or the player changes their profile. hosting is optional, true only with a game, and only in the player's own room. avatar is optional: an avatar id (uniplay:profile) or null. platinum is optional (SDK 1.4): true shows the platinum version of that avatar, and only counts with an avatar. voice and muted are optional (SDK 1.3): muted only counts with voice. Nothing else from a peer counts until its hello. A hello carrying the receiver's own profile is ignored (the same player in another tab), and a hello whose profile is already in the room under another connection replaces that connection without a join or leave.
u:chat { "text" } A chat line: at most 200 characters after collapsing whitespace, at most 8 per 10 seconds per peer. Show it as text, never as HTML.
g:<game id>:<name> anything A game's own channel; name is letters, digits and -.

The room's owner is the peer whose room is the room's code: in co-op games, the owner hosts and everyone else joins as a guest. A game announces that it's hosting with uniplay.net.startHosting(); Buds then shows "Hosting " and a chat card with a Join button. Join opens the game for the bud and hands it an invite, the host's room code, through uniplay.net.onInvite(cb); the game joins the way its own Join does. /<path>/#join=CODE links do the same. Leaving a game isn't leaving the room: a guest whose host closes the game stays in the room, and only goes back to their own when the owner has left too. Names and profile ids are whatever the peer says: use them to show and recognise people, not to trust them.

import { joinRoom } from 'trystero';

// Outside the Hub, the game opens its own room with Trystero; inside, it uses the Hub's.
const uniplay = new Uniplay({ game: 'heated-heist', net: { joinRoom } });
const net = uniplay.net; // null when there's no room
const moves = net.channel<{ x: number; y: number }>('move');
moves.onMessage((move, peer) => moveGuest(peer.id, move));
net.onJoin((peer) => addGuest(peer));
net.onLeave((peer) => removeGuest(peer));
net.onInvite((code) => net.join(code));
hostButton.onclick = () => (net.home(), net.startHosting());

Each room has one voice call, which anyone in it can start or join. A peer in the call says "voice": true in its hello and sends its microphone as a WebRTC audio stream (a Trystero stream, not a channel) only to the peers whose hello also says "voice": true, and stops sending to any that leave the call. Peers outside the call never receive anyone's audio. Muting keeps the peer in the call with a silent track and "muted": true. Moving to another room, or closing the page, leaves the call. In the Hub the call belongs to the Hub page, so it carries on across games; games don't take part in it.