Rooms and rules
Rooms hold what people share live, like one game of tic-tac-toe or one shopping list. A room's state lives on Tinylib's servers, and only your rules in rules.js change it.
The examples build tic-tac-toe. For a turn-based game with friends, tinylib-sdk/kits builds the user's list of games, invitations, and rematches on top of rooms.
Each kind of room is a named export of rules.js with a create function, which returns a new room's first state. The client creates a room by the export's name:
ctx.user is the room's creator and first member. options is what the client passed as create's second argument, so check it before you use it.
address is the screen of your app that shows a room of this kind, with :id for the room's id. The room's notifications and its link open your app there.
tinylib.rooms.follow opens a room, calls your function with each new view, and returns a function that closes it:
errorcomes once, when the room can't be opened:not_memberif the user isn't in it,endedif it has ended. Every other call has the openroom.viewis null until the first view arrives.- When the room ends or the user leaves it, the last call has
room.endedorroom.lefttrue. A room's final view arrives before it ends. - Without a connection,
followwaits and opens the room once the device is back online, unless the room is of an offline kind.
Call stop() when the room's screen goes away. Moving to another screen doesn't close the room, and a member gets no notifications from a room they have open.
tinylib.rooms.open resolves with the same open room, for code that manages it directly. Close it with room.close().
A room starts with only its creator. Everyone else joins by an invitation, the room's link, or a code. tinylib.rooms.invite(id) opens Tinylib's invite sheet over your app:
The sheet lists the user's friends, including ones who don't have your app yet, and offers the room's link. Your app never learns who was picked. To invite someone your app already knows, pass their id as tinylib.rooms.invite(id, userId): a friend from tinylib.friends.list(), or a member of another of the user's rooms.
tinylib.rooms.getLink(id) resolves with the room's link as url. Opening it makes the person a member and opens your app at the kind's address. The link changes from time to time, so call getLink each time you share it. With { code: true }, it also resolves with a short code for people to type into your app:
A code is easier to guess than a link, so ask for one only when people will type it.
For people next to the user, show the code or a QR code of the link, and keep an Invite button for everyone else.
The kind's canInvite decides who may invite; without it, every member may. The link and code work only while it returns true for some member.
join runs after someone has joined, so it can't turn them away. It never runs for the room's creator. In it, ctx.user is the person who joined, and via says how:
Joins run one at a time, and Tinylib asks canInvite again after each, so two people can't both take the last seat. A room holds up to 64 members.
If your app shows people to each other, the Developer Terms require it to let them block and report each other.
A client sends an action with room.act(name, data), and Tinylib runs the kind's actions[name](state, data, ctx), which changes state in place. Any member can send any JSON value as data, so check its type and range.
To turn an action down, call ctx.refuse with a message. Nothing the handler did takes effect, and only the member who acted sees the message:
The member's call rejects with the code refused and your message:
ctx.refuse stops the handler, and a try/catch in your rules can't catch it. Only create and actions can refuse, since every other handler runs for something that has already happened. If a handler throws, nothing it did takes effect and the call rejects with failed. The error shows in npx tinylib-sdk dev and on your app's developer page.
Online, Tinylib sends the new view before room.act resolves.
Clients never get the state itself. After every input (anything that runs a handler), Tinylib calls the kind's view(state, member, members) for each member and sends the result to that member's open clients if it changed. That's how a game keeps secrets: a player's hand stays on Tinylib's servers unless view puts it in their view.
members lists the room's current members, each with their id, name, and guest. Keep view pure, since it runs for every member after every input.
For a one-off like a sound, emit an event with ctx.emit(to, event). to is 'everyone', 'user' for the member the input is about, one member's id, or a list of ids. Events reach only the clients that have the room open, after the same input's view, and Tinylib doesn't keep them. Anything a member must see belongs in view.
In the client, add a listener once, on the first call that has a room. Events that arrive before you add it wait for it:
An action runs your rules and saves the room's state, which is more than a game needs for each player's position many times a second. A relay does neither. room.relay(numbers) sends an array of numbers straight to the room's other members, and room.onRelay hears theirs, with when each was sent:
When you call stop, clear the interval too, since room.relay throws once the room is closed.
from is the sender's id, as in members, and at is when their client sent it, on the clock tinylib.now() reads. It comes from the sender's device, so it can read up to 100 milliseconds later than that clock. A relay reaches every other member who has the room open, in each client they have it open in. One client's relays arrive in the order they were sent. They don't wait for the rules, so a relay can arrive before the view or event of an action sent earlier. A relay that reaches the room before a view or event is sent always arrives before it.
A relay can wait up to 25 ms in the room, and the network can hold several and hand them over at once, so drawing each relay as it arrives makes others stutter. Draw each player where they were about 100 ms ago instead, between the two relays around that time, as Valve's Source engine and Gabriel Gambetta's entity interpolation do. A relay late by less than that delay is never seen:
Call drawn for each player in every frame you draw. When the newest relay is older than the delay, as after a dropped connection, drawn holds the player where they were last seen.
Numbers between -10 billion and 10 billion arrive rounded to 5 decimal places, and others arrive as sent.
Tinylib keeps no relays. A member who opens the room later gets none of the earlier ones, a device that is reconnecting misses those sent meanwhile, and one sent before the room has connected, or while the device is reconnecting, is lost without an error. Send the whole current value each time, not a change to it. A member who closes the room stops sending without a word, so take who's playing from the view, not from the relays.
A relay holds at most 32 numbers, and each client can send 30 per second a second in each room. Past that, room.relay throws with the code limit.
A relay's numbers and its at both come from the sender's device, which can send anything. Unless the kind has a beat, your rules never see a relay; send anything they must check or keep with room.act.
A game that moves in real time needs its rules to check every player's moves and to run on their own, such as to bring a player back after they die. Give the kind a beatMs and a beat, and the rules run every beatMs with every relay members sent since the last beat:
Each relay has from, data and at, as onRelay gets them, in the order the room took them. data and at are what the sender's device sent, with at at most 100 milliseconds later than ctx.now, so check them as you'd check an action's data. A beat is one input like any other: it runs after the inputs before it, the state is saved once after it, and members get their views. beatMs is from 50 milliseconds to 1 second; for anything slower, set a timer.
Members get a beat's views and events before its save is confirmed. If that save fails, which is rare, the room goes back to its last saved state, a few beats earlier, and members reconnect to it, so a member can have seen a view or event that's undone. A beat that changes a status, writes a member's data, sends a notification or ends the room is saved before anyone sees it.
So positions travel once: other members draw them as relays, and the rules check them at the next beat. Send what happens once, such as a hit or a pickup, with room.act as soon as it happens, so it doesn't wait for a beat. An action runs as soon as it reaches the room, before the beat that would carry the relays sent just before it, so give the action what the rules need to check it. Actions have a limit a second, so send what happens together, such as every pellet of one blast that hits, in one action.
The beats run while at least one member has the room open, starting a beat after someone opens it, and stop once nobody has it open. A relay lost while its sender reconnects never reaches a beat, so a player's next relay can come after a gap; let the rules accept any move the player could have made in that time.
After every input, the kind's status(state, member, members) returns each member's status: a line of text, and whether the room is waiting on them. Tinylib shows it beside the room on your app's page in Tinylib, and lists the rooms waiting on the user across all their apps. Your client reads statuses from tinylib.rooms.list().
A status never notifies anyone. To tell a member it's their turn, call ctx.notify in the handler that makes it so:
ctx.notify sends nothing to a member who has the room open on any device, even in a background tab. Notifications also need their entry in uses, covered in Notify a room's members.
A room ends when the rules call ctx.end(), as move does above. Each member keeps it among their past rooms, with their last status and its room data.
It also ends when its last member leaves with tinylib.rooms.leave(id) or is removed with ctx.remove(id), and when nobody has opened, acted in, joined, or left it for the kind's idleMs, which defaults to 7 days.
No handler runs when a room ends. Its timers and link stop, and opening it rejects with ended, so keep anything members should see afterward in room data.
A game played in one sitting can set a short idleMs, like an hour:
A room never ends while a member has it open.
List the user's unfinished games on your start screen. tinylib.rooms.list() returns them with ended and left false, and followGames returns them in current.
A room doesn't have to end. To play again, reset the room with an action, like this lobby action:
Everyone stays a member, guests included. New members can join while the kind's canInvite allows it. If the host leaves, pick a new host in the kind's leave. Keep a Leave button, since a reset room stays open.
To play again in a new room, create it with rematchOf. Tinylib invites the old room's other members who have an account:
Their invitations carry rematchOf, so tinylib.invitations.handle can offer them on the finished game's screen instead of in Tinylib's sheet. In a turn-based game, playAgain does both sides of this.
Guests get no invitations. To play again with guests, reset the room as above.
Mark a kind offline: true, and members can act in its rooms without a connection, as a shared shopping list needs in a store with no signal:
An offline kind has no view. Every member gets the whole state, capped at 256 KB. A list can go weeks unopened, so this one sets idleMs to a year.
Give new things their ids in the client with crypto.randomUUID(), so a later action can name something the room doesn't have yet. Make actions set a value rather than flip it, as check sets done. An action made offline runs after whatever other members did meanwhile, and a flip would undo their change.
Online, room.act resolves once the rules accept the action. Offline, it resolves once the device has saved the action, and the device sends it when the connection comes back. If the rules then refuse it, Tinylib tells the user the change couldn't be saved.
An action stays in room.pending as { action, data }, oldest first, until the view that includes it has reached the page, or until the room refuses it, and follow calls your function when pending changes. So drawing the pending actions over the view never shows an action going back unless the room refused it. An action can briefly be in both, so apply each one in a way that's harmless twice, for example by matching its item's id:
Without signal, an offline room this device has opened before opens with the state the device last had, and connects when the signal comes back. Offline, though, tinylib.rooms.list() can't give you its id. Keep the id in the user's data and go there when the app starts:
A guest is a user without a Tinylib account. Anyone can open your app without one: Tinylib asks for their name the first time, and from then on that device is one user of your app, with a library, data, reminders, and rooms of its own. Signing in later moves what the device holds into their account. tinylib.user.guest in the client and a member's guest in the rules are true for a guest.
Kinds are open to guests by default. Set guests: false on a kind to keep them out: a guest's rooms.create and rooms.join for that kind reject with refused, and its rooms' links ask them to sign in first.
In the rules, a guest is a member like any other. Their id is the same in every room of your app, ctx.data(id) reads their values, and the rules write their keys as for anyone, so a record across rooms works for a guest too. join runs for them with via as 'link'.
Guests get no invitations, ctx.notify sends them nothing, and they can't add friends.
In the client:
A block changes nothing in a room two people are already in, and Tinylib's own screens never remove anyone from a room; only the rules can, with ctx.remove. So a kind that people join by its link should let its creator remove a member. A removed user can come back by the link or code like anyone else; to keep people out once a game starts, return false from canInvite:
ctx.members lists a room's current members in the order they joined. Each has the id, name, and guest that view sees, plus joinedAt and present, which is true from when they open the room until they've had it closed for 5 seconds:
The members that view and status get don't say who has the room open. To show that, add here: {} to the state create returns, and keep it current with the kind's presence:
presence runs with present true when a member comes into the room or opens it on their first device, and with present false once they've had it closed on every device for 5 seconds.
Keep ids in the state, not names. Names in ctx.members and view's members are always current, but a copied name goes stale when the member renames themselves. ctx.setName(text) names the room in members' lists of rooms and on its invitations. ctx.remove(id) takes a member out, and leave runs with why as 'removed'.
Handlers never wait. To do something later, set a timer, and when it comes due Tinylib runs the kind's timer handler of that name. This quiz question closes after 20 seconds:
ctx.setTimer(name, at, data) passes data to the timer's handler. A room has one timer of each name, so setting one again moves it, and ctx.cancelTimer(name) cancels it. Members can't see timers, so put the deadline in the view. The client counts down on Tinylib's clock with tinylib.now():
Decide anything time-based, like whether an answer came in time, from ctx.now in the action. A timer is for when nobody acts.
To keep a game's result after it ends, write it to room data from the rules. Every member can read room data, during the room and after it ends, so keep secrets out of it. It's capped at 256 KB.
A member's record across rooms, like their wins, belongs in their own data, under keys only the rules write.
Rooms always run the current version, so new rules must handle state in the shape earlier rules wrote. Publishing covers migrate and the check Tinylib runs on open rooms before a version goes live.