Tinylib
DiscoverDocsSign in
Sign in
Tinylib
AboutDiscoverDocsMake an appPricingTermsPrivacyDeveloper TermsContact

Start

  • Quickstart
  • How Tinylib works

Guides

  • Data
  • Rooms and rules
    • Create a room
    • Open a room
    • Invite people
    • Handle an action
    • What members see
    • Send live data with a relay
    • Run the rules on a beat
    • Statuses and notifications
    • End a room
    • Play again
    • Handlers
    • Offline kinds
    • Guests
    • Members and presence
    • Act later with a timer
    • Keep data with a room
    • A new version
  • Games
  • Friends
  • Places and links
  • Reminders and notifications
  • Publishing
  • Errors and offline
  • Platforms

Client API

  • tinylib
  • tinylib.data
  • tinylib.rooms
  • tinylib.invitations
  • tinylib.friends
  • tinylib.places
  • tinylib.reminders
  • Types
  • tinylib-sdk/kits

Rules API

  • Kinds
  • ctx

Reference

  • tinylib.json
  • Command line
  • Limits

Plain text

  • llms.txt
  • llms-full.txt

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.

Members' devices Tinylib's servers Ben Your client Ada Your client Room view Handler State Member data changes only Ben's only Ada's create join action refusal writes timer notify Your code Tinylib
Members' devices Tinylib's servers Ben Your client Ada Your client Room view Handler State Member data changes only Ben's only Ada's create join action refusal timer writes notify Your code Tinylib

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.

Create a room

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:

export const game = { address: 'game/:id', create(options, ctx) { return { board: Array(9).fill(''), players: { x: ctx.user.id, o: null }, turn: 'x', winner: null } }, // ... }

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.

Open a room

tinylib.rooms.follow opens a room, calls your function with each new view, and returns a function that closes it:

app.js
const stop = tinylib.rooms.follow(id, ({ view, room, error }) => { if (error) return toast(error.message) if (room.left) return tinylib.places.go('') if (view) draw(view, room.ended) })
  • error comes once, when the room can't be opened: not_member if the user isn't in it, ended if it has ended. Every other call has the open room.
  • view is null until the first view arrives.
  • When the room ends or the user leaves it, the last call has room.ended or room.left true. A room's final view arrives before it ends.
  • Without a connection, follow waits 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().

Invite people

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:

app.js
document.querySelector('#invite').onclick = async () => { try { await tinylib.rooms.invite(id) } catch (error) { toast(error.message) } }

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:

app.js
const form = document.querySelector('#join') form.onsubmit = async (event) => { event.preventDefault() try { const { id } = await tinylib.rooms.join(form.elements.code.value) tinylib.places.go(`game/${id}`) } catch (error) { toast(error.message) } }

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:

rules.js
export const game = { // ... canInvite(state) { return state.players.o === null }, join(state, via, ctx) { state.players.o = ctx.user.id }, }

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.

Handle an action

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:

rules.js
const LINES = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]] function wins(board, mark) { return LINES.some((line) => line.every((square) => board[square] === mark)) } export const game = { // ... actions: { move(state, square, ctx) { if (state.winner) ctx.refuse('This game is over.') if (state.players[state.turn] !== ctx.user.id) ctx.refuse("It's not your turn.") if (!Number.isInteger(square) || square < 0 || square > 8 || state.board[square] !== '') { ctx.refuse('Pick an empty square.') } state.board[square] = state.turn if (wins(state.board, state.turn)) state.winner = state.turn else if (!state.board.includes('')) state.winner = 'draw' if (state.winner) ctx.end() else state.turn = state.turn === 'x' ? 'o' : 'x' }, }, }

The member's call rejects with the code refused and your message:

app.js
try { await room.act('move', 4) } catch (error) { if (error.code !== 'refused') throw error toast(error.message) // "It's not your turn." }

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.

What members see

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.

export const game = { // ... view(state, member) { const you = state.players.x === member.id ? 'x' : state.players.o === member.id ? 'o' : null return { board: state.board, turn: state.turn, winner: state.winner, you } }, }

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.

rules.js
export const game = { // ... actions: { move(state, square, ctx) { // ... ctx.emit('everyone', { sound: 'place' }) }, }, }

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:

app.js
let listening = false const stop = tinylib.rooms.follow(id, ({ view, room, error }) => { if (error) return toast(error.message) if (!listening) { listening = true room.onEvent((event) => play(event.sound)) } // ... })

Send live data with a relay

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:

app.js
let sending = null const stop = tinylib.rooms.follow(id, ({ room }) => { if (!room) return if (room.ended || room.left) return clearInterval(sending) if (sending) return room.onRelay((from, [x, y], at) => heard(from, { x, y, at })) sending = setInterval(() => room.relay([me.x, me.y]), 50) })

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:

app.js
const DELAY = 100 const players = new Map() function heard(from, relay) { const list = players.get(from) ?? [] players.set(from, list) list.push(relay) while (list.length > 2 && list[1].at < tinylib.now() - 1000) list.shift() } function drawn(from) { const list = players.get(from) ?? [] const t = tinylib.now() - DELAY const next = list.findIndex((relay) => relay.at > t) if (next === -1) return list.at(-1) if (next === 0) return list[0] const [a, b] = [list[next - 1], list[next]] const u = (t - a.at) / (b.at - a.at) return { x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u } }

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.

Run the rules on a beat

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:

rules.js
export const race = { // ... beatMs: 100, beat(state, relays, ctx) { for (const { from, data: [x, y], at } of relays) { const car = state.cars[from] if (!car || at <= car.at || Math.hypot(x - car.x, y - car.y) > (TOP_SPEED * (at - car.at)) / 1000) continue Object.assign(car, { x, y, at }) } if (ctx.now >= state.endsAt) state.over = true }, }

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.

Statuses and notifications

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:

rules.js
export const game = { // ... status(state, member) { if (state.winner === 'draw') return { text: 'Draw', waiting: false } if (state.winner) return { text: state.players[state.winner] === member.id ? 'You won' : 'You lost', waiting: false } if (state.players[state.turn] === member.id) return { text: 'Your turn', waiting: true } return { text: 'Their turn', waiting: false } }, actions: { move(state, square, ctx) { // ...after passing the turn const next = state.players[state.turn] if (next) ctx.notify(next, { title: 'Your turn', text: `${ctx.user.name} played.` }) }, }, }

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.

End a room

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:

rules.js
export const quiz = { address: 'quiz/:id', idleMs: 60 * 60 * 1000, // ... }

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.

Play again

A room doesn't have to end. To play again, reset the room with an action, like this lobby action:

rules.js
export const arena = { // ... actions: { // ... lobby(state, data, ctx) { if (ctx.user.id !== state.host) ctx.refuse('Only the host can go back to the lobby.') if (state.phase !== 'results') ctx.refuse('The match is still on.') state.phase = 'lobby' state.results = null }, }, }

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:

app.js
const { id: rematchId } = await tinylib.rooms.create('game', {}, { rematchOf: id }) tinylib.places.go(`game/${rematchId}`)

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.

Handlers

Tinylib runs a room's inputs one at a time, in the order they arrive. Handlers are synchronous and can't reach the network, so keep everything in state, and use ctx.now for the time and ctx.random() for chance. Kinds lists every handler and when it runs, and ctx lists what a handler can do.

Offline kinds

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:

export const list = { offline: true, address: 'list/:id', idleMs: 365 * 24 * 60 * 60 * 1000, create() { return { items: [] } }, actions: { add(state, item, ctx) { if (typeof item?.id !== 'string' || typeof item.text !== 'string') ctx.refuse("That item couldn't be added.") if (state.items.some((each) => each.id === item.id)) return state.items.push({ id: item.id, text: item.text, done: false }) }, check(state, data) { const item = state.items.find((each) => each.id === data?.id) if (item && typeof data.done === 'boolean') item.done = data.done }, }, }

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:

app.js
const stop = tinylib.rooms.follow(id, ({ view, room, error }) => { if (error) return toast(error.message) if (!view) return const items = view.items.map((item) => ({ ...item })) for (const { action, data } of room.pending) { const item = items.find((each) => each.id === data.id) if (action === 'add' && !item) items.push({ ...data, done: false, waiting: true }) if (action === 'check' && item) item.done = data.done } drawList(items) })

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:

app.js
// On the list's screen tinylib.data.set('list', id) // When the app starts const saved = tinylib.data.get('list') if (saved && tinylib.places.address === '') tinylib.places.go(`list/${saved}`)

Guests

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.

rules.js
export const league = { guests: false, // ... }

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:

CallFor a guest
tinylib.data; rooms.create, open, follow, join, leave, getLink, list, and data; room.act; reminders.set, list, and cancel; and block
For a guest
Work as for anyone. A reminder goes off on the device that set it.
rooms.create and join for a kind with guests: false
For a guest
Reject with refused.
rooms.invite with a user's id, rooms.create with rematchOf, every friends call, an invitation's accept and dismiss, and report
For a guest
Reject with refused. Made as the user taps, the call first opens Tinylib's offer of an account, so a Rematch or Send to a friend button can stay for a guest.
rooms.invite with the room alone
For a guest
Opens the invite sheet with the room's link, and no friends to pick.
invitations.list()
For a guest
Resolves with [].
user.manageSettings()
For a guest
Opens Tinylib's sign-in offer instead of the switches, and resolves with settings unchanged.

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:

rules.js
export const quiz = { create(options, ctx) { return { host: ctx.user.id, phase: 'lobby', scores: { [ctx.user.id]: 0 } } }, canInvite(state) { return state.phase === 'lobby' }, join(state, via, ctx) { state.scores[ctx.user.id] = 0 }, leave(state, why, ctx) { delete state.scores[ctx.user.id] }, actions: { remove(state, id, ctx) { if (ctx.user.id !== state.host) ctx.refuse('Only the host can remove a player.') if (typeof id !== 'string' || !(id in state.scores) || id === state.host) ctx.refuse('Pick a player.') ctx.remove(id) }, }, // ... }

Members and presence

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:

rules.js
export const quiz = { // ... actions: { start(state, data, ctx) { // ... const here = ctx.members.filter((member) => member.present) if (here.length < 2) ctx.refuse('Wait for someone else to open the quiz.') // ... }, }, }

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:

rules.js
export const quiz = { // ... presence(state, present, ctx) { state.here[ctx.user.id] = present }, }

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'.

Act later with a timer

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:

rules.js
export const quiz = { // ... actions: { start(state, data, ctx) { if (state.phase !== 'lobby') ctx.refuse('The quiz has started.') // ... state.phase = 'question' state.deadline = ctx.now + 20_000 ctx.setTimer('reveal', state.deadline) }, }, timers: { reveal(state, data, ctx) { state.phase = 'reveal' }, }, view(state, member) { return { phase: state.phase, deadline: state.deadline, score: state.scores[member.id] } }, }

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():

app.js
const seconds = Math.ceil((view.deadline - tinylib.now()) / 1000)

Decide anything time-based, like whether an answer came in time, from ctx.now in the action. A timer is for when nobody acts.

Keep data with a room

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.

export const game = { // ... actions: { move(state, square, ctx) { // ...set state.winner on a win or a draw if (state.winner) { ctx.data.room.set('final', { board: state.board, winner: state.winner }) ctx.end() } else { state.turn = state.turn === 'x' ? 'o' : 'x' } }, }, }

A member's record across rooms, like their wins, belongs in their own data, under keys only the rules write.

A new version

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.

PreviousDataNextGames

On this page

  • Create a room
  • Open a room
  • Invite people
  • Handle an action
  • What members see
  • Send live data with a relay
  • Run the rules on a beat
  • Statuses and notifications
  • End a room
  • Play again
  • Handlers
  • Offline kinds
  • Guests
  • Members and presence
  • Act later with a timer
  • Keep data with a room
  • A new version